// L2 plan building: replies and resolution on EXISTING documents.
// Replies are anchorless (the locked K2 policy): a reply splices its
// definition into comments.xml and its linkage into
// word/commentsExtended.xml — creating and wiring that part when
// absent — keyed by LAST-body-paragraph w14:paraId values. Existing
// documents may predate w14, so the RETROFIT allocates a fresh 8-hex
// paraId (collision scope: every paraId in comments.xml and all
// reachable story parts) and splices the attribute into the parent's
// last body paragraph, declaring w14 (+ mc:Ignorable) on the comments
// root when absent. commentsExtended itself is rewritten WHOLE-PART:
// it holds pure linkage (no user content), so the canonical
// re-serialization is the declared edit span.
///|
/// One commentsExtended record, in part order.
priv struct CommentExEntry {
para_id : String
done : Bool
parent_para_id : String?
}
///|
fn default_annotation_planner_xml_budget() -> @xml.XmlReadBudget {
@xml.xml_read_budget(
max_source_units=16 * 1024 * 1024,
max_tokens=262_144,
max_materialized_chars=8 * 1024 * 1024,
max_token_chars=4 * 1024 * 1024,
)
}
///|
/// Builds the splice plan for `docx annotate reply`: the reply's
/// definition (paraId-stamped) into comments.xml plus the whole-part
/// commentsExtended update (parent retrofit included). Returns the
/// plan and the reply's new comment id.
pub fn plan_comment_reply(
annotated : DocxAnnotatedResult,
original : BytesView,
parent_id~ : String,
spec : CommentSpec,
max_fragment_bytes? : Int = 8 * 1024 * 1024,
) -> (@splice.SplicePlan, String) raise DocxError {
let xml_budget = default_annotation_planner_xml_budget()
plan_comment_reply_zip(
annotated,
open_zip(original),
parent_id~,
spec,
max_fragment_bytes,
xml_budget,
)
}
///|
/// Archive-backed reply planner for bounded preservation sessions.
pub fn plan_comment_reply_archive(
annotated : DocxAnnotatedResult,
archive : @mbtzip.Archive,
parent_id~ : String,
spec : CommentSpec,
xml_budget? : @xml.XmlReadBudget,
max_fragment_bytes? : Int = 8 * 1024 * 1024,
) -> (@splice.SplicePlan, String) raise DocxError {
let xml_budget = match xml_budget {
Some(value) => value
None => default_annotation_planner_xml_budget()
}
plan_comment_reply_zip(
annotated,
open_zip_archive(archive),
parent_id~,
spec,
max_fragment_bytes,
xml_budget,
)
}
///|
fn plan_comment_reply_zip(
annotated : DocxAnnotatedResult,
zip : ZipArchive,
parent_id~ : String,
spec : CommentSpec,
max_fragment_bytes : Int,
xml_budget : @xml.XmlReadBudget,
) -> (@splice.SplicePlan, String) raise DocxError {
check_mutation_gates(annotated)
let plan = @splice.SplicePlan::new()
let parent = find_defined_comment(annotated, parent_id)
if parent.body_paragraphs() == 0 {
raise Unsupported(
message="comment '\{parent_id}' has no body paragraph to key the thread on; refusing to reply",
)
}
guard annotated.comments_part() is Some(comments_part) else {
raise Unsupported(
message="the document has no wired comments part; refusing to reply",
)
}
guard zip.read_bytes(comments_part) is Some(comments_bytes) else {
raise Unsupported(message="the comments part is unreadable")
}
// Fresh paraIds: collision scope is every paraId in the reachable
// parts (duplicates there are unrepairable and fail closed).
let used_para_ids = collect_para_ids(
zip,
annotated,
Some(comments_part),
xml_budget,
)
let mut next_para_seed = 1
fn allocate_para_id() -> String {
let mut candidate = format_para_id(next_para_seed)
while used_para_ids.contains(canonical_para_id(candidate)) {
next_para_seed += 1
candidate = format_para_id(next_para_seed)
}
next_para_seed += 1
used_para_ids.add(canonical_para_id(candidate))
candidate
}
// The comments root: which prefix carries the w14 URI (or a root
// retrofit that declares it).
guard annotated.story_root_span("/comments") is Some(root_span) else {
raise Unsupported(
message="the comments part could not be scanned; refusing to reply",
)
}
let w14_prefix = ensure_w14_on_root(
plan, comments_part, comments_bytes, root_span,
)
// Parent retrofit: the parent's LAST body paragraph needs a paraId.
let parent_para_id = match parent.last_para_id() {
Some(existing) => existing
None => {
let fresh = allocate_para_id()
let ordinal = comment_ordinal(annotated, parent_id)
let last_paragraph = parent.body_paragraphs()
guard annotated.story_paragraph_span(
"/comments",
"comment[\{ordinal}]/p[\{last_paragraph}]",
)
is Some(paragraph_span) else {
raise Unsupported(
message="comment '\{parent_id}' has no scannable last body paragraph; refusing to reply",
)
}
insert_open_tag_attribute(
plan,
comments_part,
comments_bytes,
paragraph_span,
" \{w14_prefix}:paraId=\"\{fresh}\"",
)
fresh
}
}
// The reply definition: dense id, every body paragraph stamped, the
// LAST stamp being the reply's commentEx key.
check_comment_body_for_fragment(spec.body, max_fragment_bytes)
let new_id = allocate_comment_id(annotated)
let reply_para_ids : Array[String] = []
for _ in 0..
plan.edit_part(
comments_part,
@splice.span_edit(
start=insert_at,
end=insert_at,
@utf8.encode(definition),
),
)
None =>
// A defined parent implies a populated, open-form root.
raise Unsupported(
message="the comments part has a self-closing root yet holds definitions; refusing to reply",
)
}
guard reply_para_ids.last() is Some(reply_key) else {
raise Unsupported(message="the reply body is empty")
}
// commentsExtended: ENTRY-LEVEL edits — a parent entry is created
// only when missing (an existing one keeps its bytes, resolution
// state included), and the reply's entry appends with paraIdParent
// linking the thread.
let additions : Array[CommentExEntry] = []
let mut parent_seen = false
match annotated.comments_extended_part() {
Some(part) =>
match zip.read_bytes(part) {
Some(bytes) => {
let (locations, _, _) = scan_comment_ex_part(bytes)
for location in locations {
if location.para_id == canonical_para_id(parent_para_id) {
parent_seen = true
}
}
}
None =>
raise Unsupported(message="the commentsExtended part is unreadable")
}
None => ()
}
if !parent_seen {
additions.push({
para_id: canonical_para_id(parent_para_id),
done: false,
parent_para_id: None,
})
}
additions.push({
para_id: canonical_para_id(reply_key),
done: false,
parent_para_id: Some(canonical_para_id(parent_para_id)),
})
queue_comments_extended_edits(
plan,
zip,
annotated,
None,
additions,
xml_budget,
)
pin_splice_sources(plan, zip)
(plan, new_id)
}
///|
/// Builds the splice plan for `docx annotate resolve|unresolve`: the
/// comment's commentsExtended entry flips (or gains) its w15:done flag,
/// with the same paraId retrofit as replies when the definition is
/// unstamped.
pub fn plan_comment_resolution(
annotated : DocxAnnotatedResult,
original : BytesView,
comment_id~ : String,
done~ : Bool,
) -> @splice.SplicePlan raise DocxError {
plan_comment_resolution_zip(
annotated,
open_zip(original),
comment_id~,
done~,
default_annotation_planner_xml_budget(),
)
}
///|
/// Archive-backed resolution planner for bounded preservation sessions.
pub fn plan_comment_resolution_archive(
annotated : DocxAnnotatedResult,
archive : @mbtzip.Archive,
comment_id~ : String,
done~ : Bool,
xml_budget? : @xml.XmlReadBudget,
) -> @splice.SplicePlan raise DocxError {
let xml_budget = match xml_budget {
Some(value) => value
None => default_annotation_planner_xml_budget()
}
plan_comment_resolution_zip(
annotated,
open_zip_archive(archive),
comment_id~,
done~,
xml_budget,
)
}
///|
fn plan_comment_resolution_zip(
annotated : DocxAnnotatedResult,
zip : ZipArchive,
comment_id~ : String,
done~ : Bool,
xml_budget : @xml.XmlReadBudget,
) -> @splice.SplicePlan raise DocxError {
check_mutation_gates(annotated)
let plan = @splice.SplicePlan::new()
let comment = find_defined_comment(annotated, comment_id)
if comment.body_paragraphs() == 0 {
raise Unsupported(
message="comment '\{comment_id}' has no body paragraph to key resolution on; refusing",
)
}
guard annotated.comments_part() is Some(comments_part) else {
raise Unsupported(
message="the document has no wired comments part; refusing",
)
}
guard zip.read_bytes(comments_part) is Some(comments_bytes) else {
raise Unsupported(message="the comments part is unreadable")
}
// The duplicate-paraId gate runs UNCONDITIONALLY (review round 1):
// resolving on an ambiguous key would mutate the wrong thread.
let used_para_ids = collect_para_ids(
zip,
annotated,
Some(comments_part),
xml_budget,
)
let key = match comment.last_para_id() {
Some(existing) => existing
None => {
let mut seed = 1
let mut fresh = format_para_id(seed)
while used_para_ids.contains(canonical_para_id(fresh)) {
seed += 1
fresh = format_para_id(seed)
}
guard annotated.story_root_span("/comments") is Some(root_span) else {
raise Unsupported(
message="the comments part could not be scanned; refusing",
)
}
let w14_prefix = ensure_w14_on_root(
plan, comments_part, comments_bytes, root_span,
)
let ordinal = comment_ordinal(annotated, comment_id)
let last_paragraph = comment.body_paragraphs()
guard annotated.story_paragraph_span(
"/comments",
"comment[\{ordinal}]/p[\{last_paragraph}]",
)
is Some(paragraph_span) else {
raise Unsupported(
message="comment '\{comment_id}' has no scannable last body paragraph; refusing",
)
}
insert_open_tag_attribute(
plan,
comments_part,
comments_bytes,
paragraph_span,
" \{w14_prefix}:paraId=\"\{fresh}\"",
)
fresh
}
}
queue_comments_extended_edits(
plan,
zip,
annotated,
Some((key, done)),
[],
xml_budget,
)
pin_splice_sources(plan, zip)
plan
}
///|
fn find_defined_comment(
annotated : DocxAnnotatedResult,
id : String,
) -> CommentInfo raise DocxError {
for comment in annotated.annotations().comments() {
if comment.id() == id {
if !comment.defined() {
raise Unsupported(
message="comment '\{id}' has markers but no definition; refusing",
)
}
return comment
}
}
raise Unsupported(message="the document has no comment with id '\{id}'")
}
///|
/// The parent's 1-based ordinal among DEFINITIONS in part order (the
/// scanner's comment[k] paths follow the same order).
fn comment_ordinal(
annotated : DocxAnnotatedResult,
id : String,
) -> Int raise DocxError {
for index, pair in annotated.comment_bodies() {
let (comment_id, _) = pair
if comment_id == id {
return index + 1
}
}
raise Unsupported(message="the document has no comment with id '\{id}'")
}
///|
fn spec_body_length(spec : CommentSpec) -> Int {
spec.body.length()
}
///|
/// Dense comment-id allocation (shared semantics with
/// plan_comment_addition, including the numeric-identity rules).
fn allocate_comment_id(
annotated : DocxAnnotatedResult,
) -> String raise DocxError {
let used_numeric : Set[Int] = Set([])
let mut max_numeric = -1
for comment in annotated.annotations().comments() {
match canonical_comment_id(comment.id()) {
CanonicalNumeric(value) => {
used_numeric.add(value)
if value > max_numeric {
max_numeric = value
}
}
NonNumeric => ()
OutOfRange =>
raise Unsupported(
message="the document holds comment id '\{comment.id()}', outside the supported numeric range; refusing",
)
}
}
let mut candidate = max_numeric + 1
while used_numeric.contains(candidate) {
candidate += 1
}
if candidate > 999_999_999 {
raise Unsupported(
message="cannot allocate a comment id: the document already uses the maximum supported id",
)
}
candidate.to_string()
}
///|
/// Every w14:paraId value in the reachable parts (main story, comments,
/// footnotes/endnotes, headers/footers). DUPLICATES anywhere are
/// unrepairable and fail closed — the retrofit's collision scope must
/// be trustworthy.
fn collect_para_ids(
zip : ZipArchive,
annotated : DocxAnnotatedResult,
comments_part : String?,
xml_budget : @xml.XmlReadBudget,
) -> StableStringSet raise DocxError {
let para_ids : StableStringSet = SortedSet([])
let parts : Array[String] = [annotated.main_story_part()]
let seen_parts : StableStringSet = SortedSet(parts)
match comments_part {
Some(part) if !seen_parts.contains(part) => {
seen_parts.add(part)
parts.push(part)
}
_ => ()
}
// Sibling story parts named through the MAIN part's relationships.
let logical_main = main_story_logical_part(annotated, zip)
let (main_dir, main_base) = split_part_path(logical_main)
let rels_part = if main_dir == "" {
"_rels/\{main_base}.rels"
} else {
"\{main_dir}/_rels/\{main_base}.rels"
}
let relationships = read_relationships_strict_limited(
zip, rels_part, xml_budget,
)
for
reachable in resolve_reachable_annotation_story_parts(
zip, relationships, main_dir,
) {
if !seen_parts.contains(reachable.path) {
seen_parts.add(reachable.path)
parts.push(reachable.path)
}
}
for part in parts {
guard zip.read_bytes(part) is Some(bytes) else {
raise MissingPart(
message="a paraId-bearing story relationship targets missing part '\{part}'",
)
}
let root = @xml.read_xml_bytes_strict_limited(
bytes,
xml_budget,
namespace_map=office_namespace_map(),
) catch {
ResourceLimit(..) as error => raise error
error =>
raise InvalidXml(
message="could not parse paraId-bearing story part '\{part}': \{repr(error)}",
)
}
collect_para_id_attributes(root, para_ids) catch {
Unsupported(message~) =>
raise Unsupported(message="\{message} (in '\{part}')")
err => raise err
}
}
para_ids
}
///|
/// Validates every reachable story's `w14:paraId` values under one cumulative
/// XML budget. This is the candidate-side gate for generic splice plans; the
/// reply/resolution planners use the same traversal before allocating IDs.
pub fn validate_global_para_id_state_archive_limited(
annotated : DocxAnnotatedResult,
archive : @mbtzip.Archive,
xml_budget : @xml.XmlReadBudget,
) -> Unit raise DocxError {
collect_para_ids(
open_zip_archive(archive),
annotated,
annotated.comments_part(),
xml_budget,
)
|> ignore
}
///|
fn collect_para_id_attributes(
element : XmlElement,
para_ids : StableStringSet,
) -> Unit raise DocxError {
match element.attributes.get("w14:paraId") {
Some(para_id) => {
guard canonical_para_identity(para_id) is Some(canonical) else {
raise Unsupported(
message="invalid w14:paraId; the document's paraId state is not repairable",
)
}
if para_ids.contains(canonical) {
raise Unsupported(
message="duplicate w14:paraId '\{para_id}'; the document's paraId state is not repairable",
)
}
para_ids.add(canonical)
}
None => ()
}
for child in element.children {
match child {
XmlElement(inner) => collect_para_id_attributes(inner, para_ids)
_ => ()
}
}
}
///|
/// Ensures the w14 URI is bound on the comments ROOT and that the
/// chosen prefix is listed in mc:Ignorable, returning that prefix.
/// TWO-PASS over the root's attributes (declarations first, then
/// mc:Ignorable through the completed map — attribute ORDER must not
/// matter), and the Ignorable token is ensured even when the URI was
/// already bound (review round 1).
fn ensure_w14_on_root(
plan : @splice.SplicePlan,
part : String,
part_bytes : BytesView,
root_span : NodeSpan,
) -> String raise DocxError {
let attributes = scan_open_tag_attributes(part_bytes, root_span.byte_start())
let all_bindings = collect_namespace_bindings(part_bytes)
let root_bindings : StableStringMap[String] = SortedMap([])
// Pass 1: every root namespace declaration, decoded exactly as strict XML
// sees it. Entity spellings must not create a second semantic binding.
let mut w14_prefix : String? = None
let mut mc_prefix : String? = None
for attribute in attributes.entries {
let (name, value, _, _) = attribute
if name.has_prefix("xmlns:") {
let prefix = name.view(start_offset=6).to_owned()
let uri = decode_entities(value)
root_bindings[prefix] = uri
if uri == W14_NAMESPACE &&
namespace_prefix_is_stable(all_bindings, prefix, W14_NAMESPACE) &&
w14_prefix is None {
w14_prefix = Some(prefix)
}
if uri == MC_NAMESPACE && mc_prefix is None {
mc_prefix = Some(prefix)
}
}
}
// Pass 2: find the expanded mc:Ignorable attribute through any root prefix
// bound to MC, not merely the first declaration spelling.
let mut ignorable : (String, Int)? = None
for attribute in attributes.entries {
let (name, value, _, value_end) = attribute
let (prefix, local_name) = split_qualified(name)
if prefix != "" &&
local_name == "Ignorable" &&
root_bindings.get(prefix) is Some(uri) &&
uri == MC_NAMESPACE {
ignorable = Some((value, value_end))
if mc_prefix is None {
mc_prefix = Some(prefix)
}
}
}
let insert_at = attributes.tag_gt -
(if attributes.self_closing { 1 } else { 0 })
let chosen = match w14_prefix {
Some(prefix) => prefix
None => fresh_namespace_prefix(all_bindings, "w14", "mbtw14")
}
let chosen_mc = match mc_prefix {
Some(prefix) => prefix
None => fresh_namespace_prefix(all_bindings, "mc", "mbtmc")
}
let mut declarations = ""
if w14_prefix is None {
declarations += " xmlns:\{chosen}=\"\{W14_NAMESPACE}\""
}
if mc_prefix is None {
declarations += " xmlns:\{chosen_mc}=\"\{MC_NAMESPACE}\""
}
if declarations != "" {
plan.edit_part(
part,
@splice.span_edit(
start=insert_at,
end=insert_at,
@utf8.encode(declarations),
),
)
}
// The Ignorable token, ensured in every configuration.
match ignorable {
Some((value, value_end)) => {
let mut listed = false
for token in decode_entities(value).split(" ") {
if token.to_owned() == chosen {
listed = true
}
}
if !listed {
plan.edit_part(
part,
@splice.span_edit(
start=value_end,
end=value_end,
@utf8.encode(" \{chosen}"),
),
)
}
}
None =>
plan.edit_part(
part,
@splice.span_edit(
start=insert_at,
end=insert_at,
@utf8.encode(" \{chosen_mc}:Ignorable=\"\{chosen}\""),
),
)
}
chosen
}
///|
/// All lexical prefix bindings in a strict-parsed comments part. Keeping every
/// URI observed for a prefix lets the retrofit reject root aliases that are
/// shadowed at the paragraph where the new attribute will be inserted.
fn collect_namespace_bindings(
bytes : BytesView,
) -> StableStringMap[StableStringSet] raise DocxError {
let bindings : StableStringMap[StableStringSet] = SortedMap([])
let limit = bytes.length()
let mut cursor = 0
while cursor < limit {
if bytes[cursor] != b'<' {
cursor += 1
continue
}
if ascii_bytes_start_with(bytes, cursor, "")
continue
}
if ascii_bytes_start_with(bytes, cursor, "") {
cursor = scan_past_ascii_terminator(bytes, cursor + 2, "?>")
continue
}
if ascii_bytes_start_with(bytes, cursor, "")
continue
}
if ascii_bytes_start_with(bytes, cursor, "") {
while cursor < limit && bytes[cursor] != b'>' {
cursor += 1
}
cursor += 1
continue
}
if ascii_bytes_start_with(bytes, cursor, " existing
None => {
let fresh : StableStringSet = SortedSet([])
bindings[prefix] = fresh
fresh
}
}
uris.add(decode_entities(value))
}
}
cursor = tag.tag_gt + 1
}
bindings
}
///|
fn namespace_prefix_is_stable(
bindings : StableStringMap[StableStringSet],
prefix : String,
expected_uri : String,
) -> Bool {
guard bindings.get(prefix) is Some(uris) else { return false }
for uri in uris {
if uri != expected_uri {
return false
}
}
true
}
///|
fn fresh_namespace_prefix(
bindings : StableStringMap[StableStringSet],
preferred : String,
fallback : String,
) -> String {
if !bindings.contains(preferred) {
return preferred
}
if !bindings.contains(fallback) {
return fallback
}
let mut ordinal = 1
while bindings.contains("\{fallback}\{ordinal}") {
ordinal += 1
}
"\{fallback}\{ordinal}"
}
///|
/// Splices one attribute into a paragraph's OPEN TAG (just before its
/// '>' — quote-aware, so a '>' inside an attribute value cannot fool
/// the scan).
fn insert_open_tag_attribute(
plan : @splice.SplicePlan,
part : String,
part_bytes : BytesView,
span : NodeSpan,
attribute_text : String,
) -> Unit raise DocxError {
let attributes = scan_open_tag_attributes(part_bytes, span.byte_start())
let insert_at = attributes.tag_gt -
(if attributes.self_closing { 1 } else { 0 })
plan.edit_part(
part,
@splice.span_edit(
start=insert_at,
end=insert_at,
@utf8.encode(attribute_text),
),
)
}
///|
priv struct OpenTagScan {
// (name, value, value_start, value_end) per attribute, byte offsets.
entries : Array[(String, String, Int, Int)]
// Offset of the closing '>' of the open tag.
tag_gt : Int
self_closing : Bool
}
///|
/// Scans one open tag's attributes from raw UTF-8 bytes, quote-aware.
fn scan_open_tag_attributes(
bytes : BytesView,
tag_start : Int,
) -> OpenTagScan raise DocxError {
let entries : Array[(String, String, Int, Int)] = []
let limit = bytes.length()
let mut at = tag_start + 1
fn is_space(byte : Byte) -> Bool {
byte == b' ' || byte == b'\t' || byte == b'\r' || byte == b'\n'
}
// Skip the element name.
while at < limit &&
!is_space(bytes[at]) &&
bytes[at] != b'>' &&
bytes[at] != b'/' {
at += 1
}
for ;; {
while at < limit && is_space(bytes[at]) {
at += 1
}
if at >= limit {
raise Unsupported(message="an unterminated open tag; refusing")
}
if bytes[at] == b'>' {
return { entries, tag_gt: at, self_closing: false }
}
if bytes[at] == b'/' && at + 1 < limit && bytes[at + 1] == b'>' {
return { entries, tag_gt: at + 1, self_closing: true }
}
let name_start = at
while at < limit &&
!is_space(bytes[at]) &&
bytes[at] != b'=' &&
bytes[at] != b'>' {
at += 1
}
let name = utf8_slice(bytes, name_start, at)
while at < limit && is_space(bytes[at]) {
at += 1
}
if at >= limit || bytes[at] != b'=' {
raise Unsupported(message="a malformed open tag; refusing")
}
at += 1
while at < limit && is_space(bytes[at]) {
at += 1
}
if at >= limit || !(bytes[at] == b'"' || bytes[at] == b'\'') {
raise Unsupported(message="a malformed open tag; refusing")
}
let quote = bytes[at]
at += 1
let value_start = at
while at < limit && bytes[at] != quote {
at += 1
}
if at >= limit {
raise Unsupported(message="an unterminated attribute value; refusing")
}
entries.push((name, utf8_slice(bytes, value_start, at), value_start, at))
at += 1
}
}
///|
/// A located commentEx entry in the EXISTING part: canonical paraId,
/// current done state, the byte span of the done attribute VALUE (when
/// present), and the open tag's insertion offset.
priv struct CommentExLocation {
para_id : String
done_value_span : (Int, Int)?
insert_at : Int
done_prefix : String
}
///|
/// Canonical paraId identity: ST_LongHexNumber is eight hex digits, so
/// conforming values compare case-insensitively (uppercased); anything
/// else compares verbatim.
fn canonical_para_id(value : String) -> String {
if value.length() != 8 {
return value
}
let builder = StringBuilder::new()
for unit in value {
let code = unit.to_int()
if code >= '0'.to_int() && code <= '9'.to_int() {
builder.write_char(code.to_char().unwrap_or('?'))
} else if code >= 'a'.to_int() && code <= 'f'.to_int() {
builder.write_char((code - 32).to_char().unwrap_or('?'))
} else if code >= 'A'.to_int() && code <= 'F'.to_int() {
builder.write_char(code.to_char().unwrap_or('?'))
} else {
return value
}
}
builder.to_string()
}
///|
/// Walks the EXISTING commentsExtended part at the byte level,
/// namespace-aware (root declarations plus each entry's own), and
/// returns the located entries, the offset of the root's close tag
/// (None for a self-closing root), and the root's byte span. Duplicate
/// canonical paraIds fail closed — mutating on ambiguous keys is the
/// unrepairable state the contract names.
fn scan_comment_ex_part(
bytes : BytesView,
) -> (Array[CommentExLocation], Int?, (Int, Int)) raise DocxError {
// The root open tag: the first '<' that is not a declaration,
// comment, or PI.
let limit = bytes.length()
let mut at = 0
let mut root_start = -1
while at < limit {
if bytes[at] == b'<' {
if at + 1 < limit && (bytes[at + 1] == b'?' || bytes[at + 1] == b'!') {
// Skip the declaration/PI/comment wholesale.
let close = if bytes[at + 1] == b'?' {
"?>"
} else if at + 3 < limit &&
bytes[at + 1] == b'!' &&
bytes[at + 2] == b'-' &&
bytes[at + 3] == b'-' {
"-->"
} else {
">"
}
let mut scan = at + 1
let terminator = close.code_units()
while scan <= limit - terminator.length() {
let mut matched = true
for offset, unit in terminator {
if bytes[scan + offset].to_int() != unit.to_int() {
matched = false
break
}
}
if matched {
break
}
scan += 1
}
at = scan + close.length()
continue
}
root_start = at
break
}
at += 1
}
if root_start < 0 {
raise Unsupported(message="the commentsExtended part has no root element")
}
let root_scan = scan_open_tag_attributes(bytes, root_start)
// Root-level namespace declarations.
let root_declarations : StableStringMap[String] = SortedMap([])
for attribute in root_scan.entries {
let (name, value, _, _) = attribute
if name == "xmlns" {
root_declarations[""] = decode_entities(value)
} else if name.has_prefix("xmlns:") {
root_declarations[name.view(start_offset=6).to_owned()] = decode_entities(
value,
)
}
}
if root_scan.self_closing {
return ([], None, (root_start, root_scan.tag_gt + 1))
}
// Walk depth-1 children.
let locations : Array[CommentExLocation] = []
let seen : StableStringSet = SortedSet([])
let mut cursor = root_scan.tag_gt + 1
let mut depth = 1
let mut root_close = -1
while cursor < limit {
if bytes[cursor] != b'<' {
cursor += 1
continue
}
if cursor + 3 < limit &&
bytes[cursor + 1] == b'!' &&
bytes[cursor + 2] == b'-' &&
bytes[cursor + 3] == b'-' {
let mut scan = cursor + 4
while scan + 2 < limit {
if bytes[scan] == b'-' &&
bytes[scan + 1] == b'-' &&
bytes[scan + 2] == b'>' {
break
}
scan += 1
}
cursor = scan + 3
continue
}
if cursor + 1 < limit && bytes[cursor + 1] == b'?' {
let mut scan = cursor + 2
while scan + 1 < limit {
if bytes[scan] == b'?' && bytes[scan + 1] == b'>' {
break
}
scan += 1
}
cursor = scan + 2
continue
}
// CDATA sections (valid between or inside entries) are skipped
// wholesale — review round 2.
if cursor + 8 < limit &&
bytes[cursor + 1] == b'!' &&
bytes[cursor + 2] == b'[' &&
bytes[cursor + 3] == b'C' &&
bytes[cursor + 4] == b'D' &&
bytes[cursor + 5] == b'A' &&
bytes[cursor + 6] == b'T' &&
bytes[cursor + 7] == b'A' &&
bytes[cursor + 8] == b'[' {
let mut scan = cursor + 9
while scan + 2 < limit {
if bytes[scan] == b']' &&
bytes[scan + 1] == b']' &&
bytes[scan + 2] == b'>' {
break
}
scan += 1
}
cursor = scan + 3
continue
}
if cursor + 1 < limit && bytes[cursor + 1] == b'/' {
depth -= 1
if depth == 0 {
root_close = cursor
break
}
while cursor < limit && bytes[cursor] != b'>' {
cursor += 1
}
cursor += 1
continue
}
let tag = scan_open_tag_attributes(bytes, cursor)
if depth == 1 {
// Resolve this tag against root + its own declarations.
let own_declarations : StableStringMap[String] = SortedMap([])
for attribute in tag.entries {
let (name, value, _, _) = attribute
if name == "xmlns" {
own_declarations[""] = decode_entities(value)
} else if name.has_prefix("xmlns:") {
own_declarations[name.view(start_offset=6).to_owned()] = decode_entities(
value,
)
}
}
fn prefix_uri(prefix : String) -> String? {
match own_declarations.get(prefix) {
Some(uri) => Some(uri)
None => root_declarations.get(prefix)
}
}
let tag_name = utf8_slice(
bytes,
cursor + 1,
cursor + 1 + tag_name_length(bytes, cursor + 1),
)
let (tag_prefix, tag_local) = split_qualified(tag_name)
if prefix_uri(tag_prefix) is Some(uri) &&
uri == W15_NAMESPACE &&
tag_local == "commentEx" {
let mut para_id : String? = None
let mut done_span : (Int, Int)? = None
let mut done_prefix = tag_prefix
for attribute in tag.entries {
let (name, value, value_start, value_end) = attribute
let (attr_prefix, attr_local) = split_qualified(name)
if attr_prefix == "" {
continue
}
if prefix_uri(attr_prefix) is Some(attr_uri) &&
attr_uri == W15_NAMESPACE {
if attr_local == "paraId" {
para_id = Some(canonical_para_id(decode_entities(value)))
// Unprefixed elements can use the W15 default namespace, but
// attributes cannot. Reuse the paraId attribute's bound prefix
// when a missing done attribute must be inserted.
done_prefix = attr_prefix
}
if attr_local == "done" {
done_span = Some((value_start, value_end))
done_prefix = attr_prefix
}
}
}
match para_id {
Some(key) => {
if seen.contains(key) {
raise Unsupported(
message="duplicate commentsExtended records for paraId '\{key}'; the thread state is not repairable",
)
}
seen.add(key)
locations.push({
para_id: key,
done_value_span: done_span,
insert_at: tag.tag_gt - (if tag.self_closing { 1 } else { 0 }),
done_prefix,
})
}
None =>
raise Unsupported(
message="a commentsExtended record has no paraId; the thread state is not repairable",
)
}
}
}
if !tag.self_closing {
depth += 1
}
cursor = tag.tag_gt + 1
}
if root_close < 0 {
raise Unsupported(
message="the commentsExtended part's root never closes; refusing",
)
}
(locations, Some(root_close), (root_start, root_scan.tag_gt + 1))
}
///|
fn tag_name_length(bytes : BytesView, name_start : Int) -> Int {
let mut at = name_start
while at < bytes.length() &&
bytes[at] != b' ' &&
bytes[at] != b'\t' &&
bytes[at] != b'\r' &&
bytes[at] != b'\n' &&
bytes[at] != b'/' &&
bytes[at] != b'>' {
at += 1
}
at - name_start
}
///|
fn split_qualified(name : String) -> (String, String) {
match name.find(":") {
Some(colon) =>
(
name.view(end_offset=colon).to_owned(),
name.view(start_offset=colon + 1).to_owned(),
)
None => ("", name)
}
}
///|
/// A self-contained commentEx fragment (its own xmlns:w15).
fn comment_ex_fragment(entry : CommentExEntry) -> String {
let attributes : Map[String, String] = {
"w15:paraId": entry.para_id,
"w15:done": if entry.done {
"1"
} else {
"0"
},
}
match entry.parent_para_id {
Some(parent) => attributes["w15:paraIdParent"] = parent
None => ()
}
@xml.write_xml_fragment(@xml.xml_element("w15:commentEx", attributes~), namespaces={
"w15": W15_NAMESPACE,
})
}
///|
/// Queues the commentsExtended mutation as ENTRY-LEVEL byte splices —
/// foreign producer content anywhere else in the part stays verbatim
/// (review round 1). `flip` updates one existing entry's done state;
/// `additions` append before the root close (a self-closing root is
/// rewritten by its own extent). When the part does not exist, it is
/// created fresh and wired.
fn queue_comments_extended_edits(
plan : @splice.SplicePlan,
zip : ZipArchive,
annotated : DocxAnnotatedResult,
flip : (String, Bool)?,
additions : Array[CommentExEntry],
xml_budget : @xml.XmlReadBudget,
) -> Unit raise DocxError {
match annotated.comments_extended_part() {
Some(part) => {
guard zip.read_bytes(part) is Some(bytes) else {
raise Unsupported(message="the commentsExtended part is unreadable")
}
let (locations, root_close, root_span) = scan_comment_ex_part(bytes)
match flip {
Some((key, done)) => {
let mut flipped = false
for location in locations {
if location.para_id == canonical_para_id(key) {
flipped = true
match location.done_value_span {
Some((value_start, value_end)) =>
plan.edit_part(
part,
@splice.span_edit(
start=value_start,
end=value_end,
@utf8.encode(if done { "1" } else { "0" }),
),
)
None =>
plan.edit_part(
part,
@splice.span_edit(
start=location.insert_at,
end=location.insert_at,
@utf8.encode(
" \{location.done_prefix}:done=\"\{if done { "1" } else { "0" }}\"",
),
),
)
}
}
}
if !flipped {
// No entry yet: the flip becomes an addition.
additions.push({
para_id: canonical_para_id(key),
done,
parent_para_id: None,
})
}
}
None => ()
}
if additions.length() > 0 {
let mut fragment = ""
for entry in additions {
fragment += comment_ex_fragment(entry)
}
match root_close {
Some(insert_at) =>
plan.edit_part(
part,
@splice.span_edit(
start=insert_at,
end=insert_at,
@utf8.encode(fragment),
),
)
None => {
let (root_start, root_end) = root_span
plan.edit_part(
part,
self_closing_rewrite_range(bytes, root_start, root_end, fragment),
)
}
}
}
}
None => {
// Create the part fresh (fully ours) and wire it.
let all_entries : Array[CommentExEntry] = []
match flip {
Some((key, done)) =>
all_entries.push({
para_id: canonical_para_id(key),
done,
parent_para_id: None,
})
None => ()
}
for entry in additions {
all_entries.push(entry)
}
let records : Array[XmlNode] = []
for entry in all_entries {
let attributes : Map[String, String] = {
"w15:paraId": entry.para_id,
"w15:done": if entry.done {
"1"
} else {
"0"
},
}
match entry.parent_para_id {
Some(parent) => attributes["w15:paraIdParent"] = parent
None => ()
}
records.push(XmlElement(@xml.xml_element("w15:commentEx", attributes~)))
}
let part_text = "" +
@xml.write_xml_fragment(
@xml.xml_element(
"w15:commentsEx",
attributes={ "mc:Ignorable": "w15" },
children=records,
),
namespaces={ "w15": W15_NAMESPACE, "mc": MC_NAMESPACE },
)
let part_path = main_sibling_mutation_part_path(
annotated, zip, "commentsExtended.xml",
)
if zip.exists(part_path.logical) {
raise Unsupported(
message="'\{part_path.logical}' exists but is not wired as the commentsExtended part (an orphan); refusing",
)
}
plan.add_part(part_path.physical, @utf8.encode(part_text))
let rels_path = main_relationships_mutation_part_path(annotated, zip)
let relationship_type = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended"
guard zip.resolve_path(rels_path.logical) is Some(actual_rels_part) else {
raise Unsupported(
message="the main part has no relationships part; refusing",
)
}
guard zip.read_bytes(actual_rels_part) is Some(rels_bytes) else {
raise Unsupported(
message="the package index lost '\{actual_rels_part}'; refusing",
)
}
let rels_root = read_mutation_relationships(
actual_rels_part, rels_bytes, xml_budget,
)
let existing_ids : StableStringSet = SortedSet([])
collect_relationship_ids(rels_root, existing_ids)
let mut ordinal = 1
while existing_ids.contains("rIdAnnotate\{ordinal}") {
ordinal += 1
}
let rels_span = scan_xml_root_span(actual_rels_part, rels_bytes)
let relationship_name = qualified_child_name(
rels_span.name,
"Relationship",
)
plan.edit_part(
actual_rels_part,
xml_root_child_edit(
rels_bytes,
rels_span,
"<\{relationship_name} Id=\"rIdAnnotate\{ordinal}\" Type=\"\{relationship_type}\" Target=\"commentsExtended.xml\"/>",
),
)
guard zip.resolve_path("[Content_Types].xml") is Some(types_part) else {
raise Unsupported(message="the package is missing [Content_Types].xml")
}
guard zip.read_bytes(types_part) is Some(types_bytes) else {
raise Unsupported(message="the package index lost '\{types_part}'")
}
read_mutation_xml_root(
types_part,
types_bytes,
xml_budget,
expected_root="content-types:Types",
)
|> ignore
let types_span = scan_xml_root_span(types_part, types_bytes)
let override_name = qualified_child_name(types_span.name, "Override")
plan.edit_part(
types_part,
xml_root_child_edit(
types_bytes,
types_span,
opc_content_type_override_fragment(
override_name,
part_path.logical,
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
),
),
)
}
}
}
///|
/// A self-closing element (raw byte range) rewritten to open form with
/// `inner` inside — the range variant of self_closing_rewrite.
fn self_closing_rewrite_range(
part_bytes : BytesView,
start : Int,
end : Int,
inner : String,
) -> @splice.SpanEdit raise DocxError {
let slice = part_bytes[start:end]
let mut name_end = 1
while name_end < slice.length() &&
slice[name_end] != b' ' &&
slice[name_end] != b'/' &&
slice[name_end] != b'>' &&
slice[name_end] != b'\t' &&
slice[name_end] != b'\r' &&
slice[name_end] != b'\n' {
name_end += 1
}
let replacement = Buffer()
replacement.write_bytesview(slice[0:slice.length() - 2])
replacement.write_bytes(b">")
replacement.write_bytes(@utf8.encode(inner))
replacement.write_bytes(b"")
replacement.write_bytesview(slice[1:name_end])
replacement.write_bytes(b">")
@splice.span_edit(start~, end~, replacement.contents())
}