// L1 plan building: everything between "a validated CommentSpec plus
// two anchor paragraphs" and "a SplicePlan ready to splice" lives HERE,
// in a testable package, so the SDK gates exercise the exact wiring the
// CLI ships (review round 1). Fail-closed throughout; the caller owns
// file IO, the two validation belts, and atomic publication.
///|
/// Builds the splice plan that adds one comment to the ORIGINAL
/// package: anchor markers into the main part at scanner offsets
/// (self-closing paragraphs rewritten by their own extent), the
/// definition into the existing comments part (self-closing roots
/// rewritten too) or a fresh part derived from the MAIN part's
/// directory, wired with a parsed-not-guessed relationship id and a
/// content-type Override located at the BYTE level. Returns the plan
/// and the allocated comment id.
pub fn plan_comment_addition(
annotated : DocxAnnotatedResult,
original : BytesView,
at_relative~ : String,
to_relative~ : 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_addition_zip(
annotated,
open_zip(original),
at_relative~,
to_relative~,
spec,
max_fragment_bytes,
xml_budget,
)
}
///|
/// Archive-backed form used by preservation-safe edit sessions. It reuses the
/// caller's already bounded, materialized package and never inflates the DOCX
/// a second time.
pub fn plan_comment_addition_archive(
annotated : DocxAnnotatedResult,
archive : @mbtzip.Archive,
at_relative~ : String,
to_relative~ : 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_addition_zip(
annotated,
open_zip_archive(archive),
at_relative~,
to_relative~,
spec,
max_fragment_bytes,
xml_budget,
)
}
///|
fn plan_comment_addition_zip(
annotated : DocxAnnotatedResult,
zip : ZipArchive,
at_relative~ : String,
to_relative~ : String,
spec : CommentSpec,
max_fragment_bytes : Int,
xml_budget : @xml.XmlReadBudget,
) -> (@splice.SplicePlan, String) raise DocxError {
check_mutation_gates(annotated)
// Anchor spans — misses report the sibling count so agents can
// correct the ordinal, matching the paths-layer error shape.
guard annotated.body_paragraph_span(at_relative) is Some(at_span) else {
raise Unsupported(
message="'/body/\{at_relative}' does not name a body paragraph (\{describe_paragraph_siblings(annotated, at_relative)})",
)
}
guard annotated.body_paragraph_span(to_relative) is Some(to_span) else {
raise Unsupported(
message="'/body/\{to_relative}' does not name a body paragraph (\{describe_paragraph_siblings(annotated, to_relative)})",
)
}
if to_span.byte_start() < at_span.byte_start() {
raise Unsupported(
message="the range end precedes its start in the document (the range is inclusive and must be ordered)",
)
}
// Dense id allocation with NUMERIC identity: ST_DecimalNumber makes
// "0000000000" and "0" the same id, so literal spellings alone are
// not collision-safe. Numeric ids canonicalize (leading zeros
// stripped); a canonical id beyond nine digits is outside the range
// this allocator reasons about and fails closed.
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 to annotate",
)
}
}
let mut candidate = max_numeric + 1
while used_numeric.contains(candidate) {
candidate += 1
}
// Never publish an id the allocator itself refuses to read back
// (nine decimal digits is the supported range).
if candidate > 999_999_999 {
raise Unsupported(
message="cannot allocate a comment id: the document already uses the maximum supported id",
)
}
let new_id = candidate.to_string()
// Fragments (namespace-self-contained per the L0 locked rule).
let main_wordprocessing_namespace = annotated.main_wordprocessing_namespace()
let definition_namespace = annotated.comment_definition_namespace()
let (start_fragment, end_fragment) = comment_anchor_fragments(
id=new_id,
wordprocessing_namespace=main_wordprocessing_namespace,
)
let definition = comment_definition_fragment(
spec,
id=new_id,
max_output_bytes=max_fragment_bytes,
wordprocessing_namespace=definition_namespace,
)
let main_part = annotated.main_story_part()
guard zip.read_bytes(main_part) is Some(main_bytes) else {
raise Unsupported(message="the package is missing its main part")
}
let plan = @splice.SplicePlan::new()
if at_relative == to_relative {
queue_paragraph_edits(
plan,
main_part,
main_bytes,
at_span,
Some(start_fragment),
Some(end_fragment),
)
} else {
queue_paragraph_edits(
plan,
main_part,
main_bytes,
at_span,
Some(start_fragment),
None,
)
queue_paragraph_edits(
plan,
main_part,
main_bytes,
to_span,
None,
Some(end_fragment),
)
}
match annotated.comments_part() {
Some(comments_part) => {
guard annotated.story_root_span("/comments") is Some(root) else {
raise Unsupported(
message="the comments part could not be scanned; refusing to annotate",
)
}
match root.close_tag_start() {
Some(insert_at) =>
plan.edit_part(
comments_part,
@splice.span_edit(
start=insert_at,
end=insert_at,
@utf8.encode(definition),
),
)
None => {
// A self-closing (empty but wired) comments root: rewrite it
// by its own extent, like a self-closing paragraph.
guard zip.read_bytes(comments_part) is Some(comments_bytes) else {
raise Unsupported(message="the comments part is unreadable")
}
plan.edit_part(
comments_part,
self_closing_rewrite(comments_bytes, root, definition),
)
}
}
}
None => {
// Create the part NEXT TO the main part. OPC relationship and
// [Content_Types] names are logical IRIs, while SplicePlan additions are
// physical ASCII ZIP item names; keep both identities explicit.
let comments_path = main_sibling_mutation_part_path(
annotated, zip, "comments.xml",
)
// An entry with that name but NO comments relationship is an
// ORPHAN part: splicing definitions into it would leave them
// disconnected, and overwriting it would destroy data.
if zip.exists(comments_path.logical) {
raise Unsupported(
message="'\{comments_path.logical}' exists but is not wired as the comments part (an orphan); refusing to annotate",
)
}
let part_text = "" +
"" +
definition +
""
plan.add_part(comments_path.physical, @utf8.encode(part_text))
let rels_path = main_relationships_mutation_part_path(annotated, zip)
let relationship_type = annotated.new_comments_relationship_type()
let created_relationships_part = match
zip.resolve_path(rels_path.logical) {
Some(actual_rels_part) => {
guard zip.read_bytes(actual_rels_part) is Some(rels_bytes) else {
raise Unsupported(
message="the package index lost '\{actual_rels_part}'; refusing to annotate",
)
}
// Allocate the id by PARSING the rels part — quoting and
// whitespace variants make substring probes unsound. Parse
// bytes directly under the caller's cumulative mutation budget.
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",
)
let relationship = "<\{relationship_name} Id=\"rIdAnnotate\{ordinal}\" Type=\"\{relationship_type}\" Target=\"comments.xml\"/>"
plan.edit_part(
actual_rels_part,
xml_root_child_edit(rels_bytes, rels_span, relationship),
)
false
}
None => {
// A main part with no relationships part is legal; create it.
plan.add_part(
rels_path.physical,
@utf8.encode(
"" +
"" +
"" +
"",
),
)
true
}
}
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")
let relationships_override = if created_relationships_part {
opc_content_type_override_fragment(
override_name,
rels_path.logical,
"application/vnd.openxmlformats-package.relationships+xml",
)
} else {
""
}
plan.edit_part(
types_part,
xml_root_child_edit(
types_bytes,
types_span,
relationships_override +
opc_content_type_override_fragment(
override_name,
comments_path.logical,
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
),
),
)
}
}
pin_splice_sources(plan, zip)
(plan, new_id)
}
///|
/// Pins every offset-bearing edit to the exact payload from which its spans
/// were derived. This turns annotation plans into safe inputs for D1 edit
/// sessions while leaving the lower-level unpinned splice API available.
fn pin_splice_sources(
plan : @splice.SplicePlan,
zip : ZipArchive,
) -> Unit raise DocxError {
for part in plan.unpinned_edited_parts() {
guard zip.resolve_path(part) is Some(actual) && actual == part else {
raise Unsupported(
message="the splice plan does not preserve the source spelling of '\{part}'",
)
}
guard zip.read_bytes(actual) is Some(source) else {
raise Unsupported(message="the package index lost '\{actual}'")
}
plan.pin_part(part, source)
}
}
///|
/// The shared mutation gates: sidecars (relationship AND content type)
/// and structurally unrepairable annotation identity state.
fn check_mutation_gates(
annotated : DocxAnnotatedResult,
) -> Unit raise DocxError {
let sidecars = annotated.annotation_sidecars()
if sidecars.length() > 0 {
raise Unsupported(
message="the document carries annotation sidecar parts this tool cannot keep consistent (\{sidecars.join(", ")}); refusing to annotate",
)
}
check_annotation_identity_state(annotated)
}
///|
fn check_annotation_identity_state(
annotated : DocxAnnotatedResult,
) -> Unit raise DocxError {
if annotated.read_policy is TolerantProjection {
raise Unsupported(
message="a tolerant DOCX projection is read-only and cannot be used to plan annotation mutations",
)
}
match
(annotated.main_relationship_dialect, annotated.main_wordprocessing_dialect) {
(Some(TransitionalOoxml), Some(StrictOoxml))
| (Some(StrictOoxml), Some(TransitionalOoxml)) =>
raise Unsupported(
message="the officeDocument relationship dialect does not match the main WordprocessingML namespace; refusing to annotate",
)
_ => ()
}
match annotated.main_wordprocessing_dialect {
Some(main_dialect) =>
for identity in annotated.index.dialect_identities {
if !same_ooxml_dialect(
identity.relationship_dialect,
identity.root_dialect,
) {
raise Unsupported(
message="the \{identity.kind} relationship dialect does not match the target WordprocessingML namespace at '\{identity.path}'; refusing to annotate",
)
}
if !same_ooxml_dialect(identity.root_dialect, main_dialect) {
raise Unsupported(
message="the \{identity.kind} annotation story at '\{identity.path}' does not match the main WordprocessingML dialect; refusing to annotate",
)
}
}
None =>
if !annotated.index.dialect_identities.is_empty() {
raise Unsupported(
message="the main story's WordprocessingML dialect could not be determined; refusing to annotate",
)
}
}
for orphan in annotated.orphan_annotation_parts {
let (path, role) = orphan
raise Unsupported(
message="'\{path}' exists but is not wired as the \{role} part (an orphan); refusing to annotate",
)
}
let index = annotated.annotations()
if !index.identity_source_complete {
raise Unsupported(
message="the document's annotation identity sources could not be parsed and scanned completely; refusing to annotate",
)
}
let definition_spellings : StableStringMap[String] = SortedMap([])
let last_para_ids : StableStringSet = SortedSet([])
for comment in index.comments {
if !comment.defined {
continue
}
let canonical_id = match canonical_comment_id(comment.id) {
CanonicalNumeric(value) => value.to_string()
NonNumeric =>
raise Unsupported(
message="the document holds a comment definition whose w:id is not a supported ST_DecimalNumber; refusing to annotate",
)
OutOfRange =>
raise Unsupported(
message="the document holds a comment definition whose w:id is outside the supported numeric range; refusing to annotate",
)
}
match definition_spellings.get(canonical_id) {
Some(_) =>
raise Unsupported(
message="the document holds multiple comment definitions for numeric id \{canonical_id}; refusing to annotate",
)
None => definition_spellings[canonical_id] = comment.id
}
match comment.last_para_id {
Some(raw_para_id) => {
guard canonical_para_identity(raw_para_id) is Some(para_id) else {
raise Unsupported(
message="the document holds a comment whose last paragraph has an invalid w14:paraId; refusing to annotate",
)
}
if last_para_ids.contains(para_id) {
raise Unsupported(
message="the document holds multiple comments with last-paragraph paraId \{para_id}; refusing to annotate",
)
}
last_para_ids.add(para_id)
}
None => ()
}
}
fn check_story_markers(scan : StoryScan) -> Unit raise DocxError {
for marker in scan.markers {
match marker.kind {
CommentStart | CommentEnd | CommentRef => ()
FootnoteRef | EndnoteRef => continue
}
let canonical_id = match canonical_comment_id(marker.id) {
CanonicalNumeric(value) => value.to_string()
NonNumeric =>
raise Unsupported(
message="the document holds a comment marker whose w:id is not a supported ST_DecimalNumber; refusing to annotate",
)
OutOfRange =>
raise Unsupported(
message="the document holds a comment marker whose w:id is outside the supported numeric range; refusing to annotate",
)
}
match definition_spellings.get(canonical_id) {
None =>
raise Unsupported(
message="the document holds a marker for comment id \{canonical_id} without a definition; refusing to annotate",
)
Some(definition_spelling) if definition_spelling != marker.id =>
raise Unsupported(
message="the document uses multiple lexical spellings for numeric comment id \{canonical_id}; refusing to annotate",
)
Some(_) => ()
}
}
}
for story_entry in index.scans {
let (_, scan) = story_entry
check_story_markers(scan)
}
for scan in index.identity_only_scans {
check_story_markers(scan)
}
let comment_ex_para_ids : StableStringSet = SortedSet([])
for identity in index.comment_ex_identities {
guard identity.para_id is Some(raw_para_id) else {
raise Unsupported(
message="the document holds a commentsExtended record without w15:paraId; refusing to annotate",
)
}
guard canonical_para_identity(raw_para_id) is Some(para_id) else {
raise Unsupported(
message="the document holds a commentsExtended record with an invalid w15:paraId; refusing to annotate",
)
}
if comment_ex_para_ids.contains(para_id) {
raise Unsupported(
message="the document holds multiple commentsExtended records for paraId \{para_id}; refusing to annotate",
)
}
comment_ex_para_ids.add(para_id)
if !last_para_ids.contains(para_id) {
raise Unsupported(
message="the document holds a commentsExtended record for unknown paraId \{para_id}; refusing to annotate",
)
}
match identity.parent_para_id {
Some(raw_parent) => {
guard canonical_para_identity(raw_parent) is Some(parent) else {
raise Unsupported(
message="the document holds a commentsExtended record with an invalid parent paraId; refusing to annotate",
)
}
if parent == para_id || !last_para_ids.contains(parent) {
raise Unsupported(
message="the document holds a commentsExtended record with an unknown or self-referential parent paraId; refusing to annotate",
)
}
}
None => ()
}
}
}
///|
fn canonical_para_identity(value : String) -> String? {
if value.length() != 8 {
return None
}
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(unit)
} 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(unit)
} else {
return None
}
}
Some(builder.to_string())
}
///|
/// Rejects annotation state that cannot safely serve as the result of a
/// comment mutation. This is the candidate-side counterpart to the planners'
/// source gate and catches duplicate or ambiguous identities introduced by a
/// caller-supplied generic splice plan.
pub fn validate_annotation_identity_state(
annotated : DocxAnnotatedResult,
) -> Unit raise DocxError {
check_annotation_identity_state(annotated)
}
///|
priv enum CommentIdClass {
CanonicalNumeric(Int)
NonNumeric
OutOfRange
}
///|
/// ST_DecimalNumber identity: all-digit ids canonicalize by stripping
/// leading zeros; canonical forms beyond nine digits are out of range.
fn canonical_comment_id(id : String) -> CommentIdClass {
if id.length() == 0 {
return NonNumeric
}
for unit in id {
let code = unit.to_int()
if code < '0'.to_int() || code > '9'.to_int() {
return NonNumeric
}
}
let mut start = 0
let units = id.code_units()
while start < units.length() - 1 && units[start].to_int() == '0'.to_int() {
start += 1
}
let canonical_digits = units.length() - start
if canonical_digits > 9 {
return OutOfRange
}
let mut value = 0
for at in start.. XmlElement raise DocxError {
let root = @xml.read_xml_bytes_strict_limited(
bytes,
xml_budget,
namespace_map=office_namespace_map(),
) catch {
ResourceLimit(..) as error => raise error
InvalidXml(message~) => {
if message == "XML source is not valid UTF-8" {
raise Unsupported(message="'\{part}' is not UTF-8; refusing")
}
raise Unsupported(message="'\{part}' is not well-formed XML; refusing")
}
_ => raise Unsupported(message="'\{part}' is not well-formed XML; refusing")
}
if root.name != expected_root {
raise Unsupported(
message="'\{part}' has root '\{root.name}', expected '\{expected_root}'; refusing",
)
}
root
}
///|
fn collect_relationship_ids(
element : XmlElement,
ids : StableStringSet,
) -> Unit {
match element.attributes.get("Id") {
Some(id) => ids.add(@xml.collapse_xml_schema_whitespace(id))
None => ()
}
for child in element.children {
match child {
XmlElement(inner) => collect_relationship_ids(inner, ids)
_ => ()
}
}
}
///|
priv struct RawXmlRootSpan {
name : String
start : Int
end : Int
close_start : Int?
}
///|
fn ascii_bytes_start_with(bytes : BytesView, at : Int, text : String) -> Bool {
if at < 0 || at + text.length() > bytes.length() {
return false
}
for offset, unit in text.code_units() {
if bytes[at + offset].to_int() != unit.to_int() {
return false
}
}
true
}
///|
fn scan_past_ascii_terminator(
bytes : BytesView,
from : Int,
terminator : String,
) -> Int raise DocxError {
let mut at = from
while at + terminator.length() <= bytes.length() {
if ascii_bytes_start_with(bytes, at, terminator) {
return at + terminator.length()
}
at += 1
}
raise Unsupported(message="unterminated XML markup while locating a root")
}
///|
/// Locates the document element and its matching close at the byte level after
/// strict XML parsing has established well-formedness. The walk is quote-aware
/// for open tags and skips comments, CDATA, and PIs, so prefixed roots and
/// self-closing empty OPC metadata are safe mutation targets.
fn scan_xml_root_span(
part : String,
bytes : BytesView,
) -> RawXmlRootSpan raise DocxError {
let limit = bytes.length()
let mut root_start = -1
let mut cursor = 0
while cursor < limit {
if bytes[cursor] != b'<' {
cursor += 1
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, "
raise Unsupported(
message="the root name of '\{part}' is not UTF-8; refusing",
)
}
if root_tag.self_closing {
return {
name,
start: root_start,
end: root_tag.tag_gt + 1,
close_start: None,
}
}
let mut depth = 1
cursor = root_tag.tag_gt + 1
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, "") {
let close_start = cursor
while cursor < limit && bytes[cursor] != b'>' {
cursor += 1
}
if cursor >= limit {
raise Unsupported(
message="could not locate the close of '\{part}'; refusing",
)
}
depth -= 1
cursor += 1
if depth == 0 {
return {
name,
start: root_start,
end: cursor,
close_start: Some(close_start),
}
}
continue
}
if ascii_bytes_start_with(bytes, cursor, " String {
let (prefix, _) = split_qualified(root_name)
if prefix == "" {
local_name
} else {
"\{prefix}:\{local_name}"
}
}
///|
fn xml_root_child_edit(
bytes : BytesView,
root : RawXmlRootSpan,
child : String,
) -> @splice.SpanEdit raise DocxError {
match root.close_start {
Some(insert_at) =>
@splice.span_edit(start=insert_at, end=insert_at, @utf8.encode(child))
None => self_closing_rewrite_range(bytes, root.start, root.end, child)
}
}
///|
/// One self-closing node ("") rewritten to the open form
/// with `inner` inside, attributes and prefix preserved VERBATIM from
/// the original bytes.
fn self_closing_rewrite(
part_bytes : BytesView,
span : NodeSpan,
inner : String,
) -> @splice.SpanEdit raise DocxError {
let slice = part_bytes[span.byte_start():span.byte_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=span.byte_start(),
end=span.byte_end(),
replacement.contents(),
)
}
///|
/// Queues one anchored paragraph's marker edits (self-closing
/// paragraphs rewritten by their own extent).
fn queue_paragraph_edits(
plan : @splice.SplicePlan,
part : String,
part_bytes : BytesView,
span : NodeSpan,
leading : String?,
trailing : String?,
) -> Unit raise DocxError {
if span.self_closing() {
let inner = leading.unwrap_or("") + trailing.unwrap_or("")
plan.edit_part(part, self_closing_rewrite(part_bytes, span, inner))
return
}
guard span.content_start() is Some(content_start) else {
raise Unsupported(message="an open paragraph without insertion offsets")
}
guard span.close_tag_start() is Some(close_start) else {
raise Unsupported(message="an open paragraph without insertion offsets")
}
match leading {
Some(fragment) =>
plan.edit_part(
part,
@splice.span_edit(
start=content_start,
end=content_start,
@utf8.encode(fragment),
),
)
None => ()
}
match trailing {
Some(fragment) =>
plan.edit_part(
part,
@splice.span_edit(
start=close_start,
end=close_start,
@utf8.encode(fragment),
),
)
None => ()
}
}
///|
/// Splits "word/document.xml" into ("word", "document.xml").
fn split_part_path(part : String) -> (String, String) {
match part.rev_find("/") {
Some(slash) =>
(
part.view(end_offset=slash).to_owned(),
part.view(start_offset=slash + 1).to_owned(),
)
None => ("", part)
}
}
///|
/// One OPC part's logical IRI identity and exact/canonical physical ZIP item
/// spelling. Existing entries retain their exact producer spelling; new parts
/// use the canonical ASCII projection of the logical name.
priv struct MutationOpcPartPath {
logical : String
physical : String
}
///|
fn mutation_part_path_from_logical(
zip : ZipArchive,
logical : String,
role : String,
) -> MutationOpcPartPath raise DocxError {
guard @opc.zip_item_name_from_logical_part_name(logical) is Some(canonical) else {
raise Unsupported(
message="the logical \{role} part name '\{logical}' cannot be represented as a ZIP item",
)
}
{ logical, physical: zip.resolve_path(logical).unwrap_or(canonical) }
}
///|
fn main_story_logical_part(
annotated : DocxAnnotatedResult,
zip : ZipArchive,
) -> String raise DocxError {
let physical = annotated.main_story_part()
guard zip.logical_path(physical) is Some(logical) else {
raise Unsupported(
message="the main story part '\{physical}' has no logical OPC identity",
)
}
logical
}
///|
fn main_sibling_mutation_part_path(
annotated : DocxAnnotatedResult,
zip : ZipArchive,
basename : String,
) -> MutationOpcPartPath raise DocxError {
let logical_main = main_story_logical_part(annotated, zip)
let (main_dir, _) = split_part_path(logical_main)
let logical = if main_dir == "" {
basename
} else {
"\{main_dir}/\{basename}"
}
mutation_part_path_from_logical(zip, logical, basename)
}
///|
fn main_relationships_mutation_part_path(
annotated : DocxAnnotatedResult,
zip : ZipArchive,
) -> MutationOpcPartPath raise DocxError {
let logical_main = main_story_logical_part(annotated, zip)
let (main_dir, main_base) = split_part_path(logical_main)
let logical = if main_dir == "" {
"_rels/\{main_base}.rels"
} else {
"\{main_dir}/_rels/\{main_base}.rels"
}
mutation_part_path_from_logical(zip, logical, "relationships")
}
///|
fn opc_content_type_override_fragment(
element_name : String,
logical_part : String,
content_type : String,
) -> String {
@xml.write_xml_fragment(
@xml.xml_element(element_name, attributes={
"PartName": "/" + logical_part,
"ContentType": content_type,
}),
)
}
///|
/// "the level has N paragraphs" for a missed ordinal — the sibling
/// count that lets an agent correct its path (probe M, gap 2).
fn describe_paragraph_siblings(
annotated : DocxAnnotatedResult,
relative_path : String,
) -> String {
let prefix = match relative_path.rev_find("/") {
Some(slash) => relative_path.view(end_offset=slash + 1).to_owned()
None => ""
}
// A MISSING ancestor must be named as such — "holds 0 paragraphs"
// for a container that does not exist would be dishonest (probe M
// review round 1). Walk the prefix segments, verifying each.
if prefix != "" {
let mut checked = ""
for segment in prefix.split("/") {
let piece = segment.to_owned()
if piece == "" {
continue
}
let candidate = if checked == "" { piece } else { "\{checked}/\{piece}" }
let kind = match piece.find("[") {
Some(bracket) => piece.view(end_offset=bracket).to_owned()
None => piece
}
if body_node_span_of_kind(annotated, candidate, kind) is None {
return "'/body/\{candidate}' does not exist"
}
checked = candidate
}
}
let mut count = 0
let mut probe = 1
while annotated.body_paragraph_span("\{prefix}p[\{probe}]") is Some(_) {
count += 1
probe += 1
}
if prefix == "" {
"the body has \{count} top-level paragraph(s)"
} else {
"'\{prefix}' holds \{count} paragraph(s)"
}
}