// Phase-2 J1: the raw-byte story scanner. The XML tree the reader builds
// retains no source offsets, so annotation positions (and, later, L0's
// byte-span surgery) come from scanning each story part's ORIGINAL bytes:
// a namespace-aware tokenizer walks the markup, mirrors the projection
// counting the path grammar uses (p/r/tbl/tr/tc/hyperlink ordinals among
// same-kind direct siblings), records each projection node's byte span
// and sequence interval, and locates annotation markers. J1 uses it
// read-only; L0 builds mutation on the same foundation.
//
// Deliberate limits (revisited at L0's go/no-go gate): DOCTYPE subsets
// are skipped naively (stories don't carry them); attribute values ARE
// entity-decoded (predefined + numeric references); only UTF-8 parts
// are scanned (UTF-16 parts raise); legacy VML content is not modeled
// (the index's verification pass degrades positions inside it).
///|
/// Transitional and Strict WML namespace URIs — the reader supports both.
fn is_wml_uri(uri : String) -> Bool {
uri == "http://schemas.openxmlformats.org/wordprocessingml/2006/main" ||
uri == "http://purl.oclc.org/ooxml/wordprocessingml/main"
}
///|
/// A marker the scanner can locate.
priv enum ScanMarkerKind {
CommentStart
CommentEnd
CommentRef
FootnoteRef
EndnoteRef
}
///|
impl Show for ScanMarkerKind with fn output(self, logger) {
logger.write_string(
match self {
CommentStart => "commentRangeStart"
CommentEnd => "commentRangeEnd"
CommentRef => "commentReference"
FootnoteRef => "footnoteReference"
EndnoteRef => "endnoteReference"
},
)
}
///|
/// A tracked-change container the scanner can locate. The reader flattens
/// `w:ins` and drops `w:del`, so neither survives into the parsed tree:
/// revision identity exists only here, in the raw bytes.
priv enum ScanRevisionKind {
Insertion
Deletion
}
///|
impl Show for ScanRevisionKind with fn output(self, logger) {
logger.write_string(
match self {
Insertion => "ins"
Deletion => "del"
},
)
}
///|
/// One located tracked-change container. `author`/`date`/`id` are the
/// attributes AS SPELLED and stay absent when the source omits them —
/// `w:date` is optional in CT_TrackChange, and inventing one would make
/// the index assert a revision time the document never recorded.
/// `container_path` follows the marker contract above; document order is
/// the array order, so no separate sequence number is retained (adding one
/// would perturb the existing marker/node sequence arithmetic for no reader).
priv struct ScannedRevision {
kind : ScanRevisionKind
id : String?
author : String?
date : String?
container_path : String?
}
///|
/// Insertion or deletion.
fn ScannedRevision::kind(self : ScannedRevision) -> ScanRevisionKind {
self.kind
}
///|
/// The w:id attribute, as spelled, when present.
fn ScannedRevision::id(self : ScannedRevision) -> String? {
self.id
}
///|
/// The w:author attribute, as spelled, when present.
fn ScannedRevision::author(self : ScannedRevision) -> String? {
self.author
}
///|
/// The w:date attribute, LEXICAL (never converted), when present.
fn ScannedRevision::date(self : ScannedRevision) -> String? {
self.date
}
///|
/// The innermost open projection node, or None at story level.
fn ScannedRevision::container_path(self : ScannedRevision) -> String? {
self.container_path
}
///|
/// One element-name occurrence to rewrite in place, as the byte range of
/// the LOCAL NAME only (never the prefix, never the attributes). Rejecting a
/// deletion turns each `w:delText` back into `w:t` by overwriting exactly
/// these ranges, so the source namespace prefix, `xml:space`, and every
/// surrounding byte survive verbatim. `close_start` is -1 for the
/// self-closing form, which has no closing tag to rewrite.
priv struct ScannedNameEdit {
open_start : Int
open_end : Int
mut close_start : Int
mut close_end : Int
replacement : String
}
///|
/// One tracked-change ELEMENT located in the original bytes, with the byte
/// extent a resolver needs to unwrap or remove it.
///
/// This is deliberately a SEPARATE record from `ScannedRevision`, which
/// describes the revisions the reader's projection retains and is therefore
/// purged whenever a subtree is retracted (a merged cell, a deleted row).
/// Mutation cares about bytes, not about the projection: a site is recorded
/// for every tracked-change element in the part, is never retracted, and is
/// never gated on suppression — an element the resolver cannot see is an
/// element it would silently leave behind.
///
/// `supported` is false for every construct outside the resolvable set
/// (property revisions such as `w:rPr/w:ins` and `w:trPr/w:del`, moves, and
/// every `*PrChange`). Those still have to be RECORDED so a selection that
/// reaches one can refuse instead of half-resolving the document.
priv struct ScannedRevisionSite {
local_name : String
supported : Bool
id : String?
author : String?
date : String?
container_path : String?
byte_start : Int
// -1 for the self-closing form (no interior).
content_start : Int
mut close_tag_start : Int
mut byte_end : Int
// Whether the element's own start tag binds any namespace prefix. Unwrapping
// such an element would drop the binding while its former children keep
// using it, so an unwrap resolution has to refuse.
declares_namespaces : Bool
// `w:delText`/`w:delInstrText` names inside a deletion, in document order.
name_edits : Array[ScannedNameEdit]
}
///|
/// One located marker. `seq` is a total document order shared with node
/// intervals; `container_path` is the innermost open projection node
/// ("p[2]/r[1]", story-relative) or None at story level; `after_child`
/// is the last projection child closed inside that container before the
/// marker (for before/after boundary rendering).
priv struct ScannedMarker {
kind : ScanMarkerKind
id : String
id_was_present : Bool
seq : Int
container_path : String?
after_child : String?
}
///|
/// The marker kind.
fn ScannedMarker::kind(self : ScannedMarker) -> ScanMarkerKind {
self.kind
}
///|
/// The marker's w:id, as spelled.
fn ScannedMarker::id(self : ScannedMarker) -> String {
self.id
}
///|
/// Whether the source marker spelled a `w:id` attribute. The tolerant
/// projection intentionally maps an absent id to `""`, so presence must stay
/// separate to prevent a literal empty value from aliasing that sentinel.
fn ScannedMarker::id_was_present(self : ScannedMarker) -> Bool {
self.id_was_present
}
///|
/// Position in the story's total document order.
fn ScannedMarker::seq(self : ScannedMarker) -> Int {
self.seq
}
///|
/// The innermost open projection node, or None at story level.
fn ScannedMarker::container_path(self : ScannedMarker) -> String? {
self.container_path
}
///|
/// The last projection child closed inside the container before this marker.
fn ScannedMarker::after_child(self : ScannedMarker) -> String? {
self.after_child
}
///|
/// One projection node's identity and extent: path ("p[3]" or
/// "tbl[1]/tr[2]/tc[1]/p[1]", story-relative), the projection kind,
/// [open_seq, close_seq] for coverage arithmetic, and the byte span of
/// the whole element in the original part (open tag start .. close tag
/// end, exclusive) for L0.
priv struct ScannedNode {
path : String
kind : String
// Monotonic ordinal assigned when the projection node opens. Close-order
// storage alone cannot recover open order without sorting; this ordinal
// lets the scanner rebuild it with one bounded array pass.
open_order : Int
open_seq : Int
close_seq : Int
byte_start : Int
byte_end : Int
// L0 insertion offsets: content_start is just past the open tag AND
// past a leading pPr (the earliest schema-legal point for run-level
// content); close_tag_start is the '<' of the close tag. Both -1 for
// self-closing elements (no interior to insert into).
content_start : Int
close_tag_start : Int
}
///|
/// One namespace-resolved source element in opening-tag order. Its array
/// index is its stable identity; parent/child/sibling links and the same-name
/// sibling ordinal form the canonical physical tree independently of the
/// reader's flattened logical projection.
#warnings("-unused_field")
priv struct ScannedElement {
identity : Int
uri : String
local_name : String
same_name_ordinal : Int
field_char_type : String?
symbol_font : String?
symbol_char : String?
break_type : String?
bookmark_name : String?
hyperlink_relationship_id : String?
hyperlink_anchor : String?
parent_index : Int
mut first_child_index : Int
mut next_sibling_index : Int
byte_start : Int
content_start : Int
mut content_end : Int
mut byte_end : Int
// Source-retaining mode populates this map for non-empty `w:t` and
// `w:instrText` content. Both element kinds draw from the same cumulative
// lexical budget.
mut text_map : WtContentMap?
// Source-retaining consumers need to distinguish reader-semantic
// retractions from elements that were never projection nodes. In
// particular, BodyReader fails open and keeps every cell when a table has
// an unexpected row/cell shape.
mut retracted_by_vmerge : Bool
mut deleted_table_row : Bool
// Retain the reader's first-tcPr cell properties even when an ancestor was
// already suppressed. N0b2 needs them to rebuild merge visibility inside a
// continuation cell that a malformed outer table makes visible again.
mut table_cell_vmerge_continue : Bool
mut table_cell_grid_span : Int
}
///|
/// Caller-owned cumulative limits for the retained N0b source tree. The
/// lexical budget is shared by every `w:t` and `w:instrText` in one story, so
/// many individually small text nodes cannot multiply the retention allowance.
priv struct ProjectionSourceBudget {
mut elements_left : Int
// Cumulative source bytes retained as element QNames or relevant
// attribute names/values, plus derived canonical sibling-counter keys.
// Charging raw UTF-8 spans and derived output lengths before allocation is
// a conservative upper bound on their eventual UTF-16 String storage.
mut retained_chars_left : Int
// Fixed-size retained objects need their own ceiling: short attributes and
// namespace declarations can otherwise stay under the character budget
// while multiplying tuples, map entries, scope stacks, and declarations.
mut retained_tokens_left : Int
max_depth : Int
text_budget : TokenMapBudget
}
///|
let max_projection_source_elements : Int = 1_000_000
///|
let max_projection_source_attribute_chars : Int = 8 * 1024 * 1024
///|
let max_projection_source_retained_tokens : Int = 1_000_000
///|
let max_projection_source_depth : Int = 256
///|
let max_projection_source_text_bytes : Int = 64 * 1024 * 1024
///|
let max_projection_source_text_tokens : Int = 1_000_000
///|
fn projection_source_budget(
max_elements? : Int = max_projection_source_elements,
max_attribute_chars? : Int = max_projection_source_attribute_chars,
max_retained_tokens? : Int = max_projection_source_retained_tokens,
max_depth? : Int = max_projection_source_depth,
max_text_bytes? : Int = max_projection_source_text_bytes,
max_text_tokens? : Int = max_projection_source_text_tokens,
) -> ProjectionSourceBudget raise DocxError {
guard max_elements >= 0 &&
max_attribute_chars >= 0 &&
max_retained_tokens >= 0 &&
max_depth >= 0 &&
max_text_bytes >= 0 &&
max_text_tokens >= 0 else {
raise Unsupported(
message="projection source-tree budget limits must be non-negative",
)
}
{
elements_left: max_elements,
retained_chars_left: max_attribute_chars,
retained_tokens_left: max_retained_tokens,
max_depth,
text_budget: token_map_budget(max_text_bytes, max_text_tokens) catch {
TokenMapError(message) => raise Unsupported(message~)
},
}
}
///|
fn ProjectionSourceBudget::charge_element(
self : ProjectionSourceBudget,
depth : Int,
) -> Unit raise DocxError {
guard self.elements_left > 0 else {
raise @core.docx_xml_resource_limit_error(DocxXmlTokens)
}
guard depth > 0 && depth <= self.max_depth else {
raise @core.docx_xml_resource_limit_error(DocxXmlNestingDepth)
}
self.elements_left -= 1
}
///|
fn ProjectionSourceBudget::charge_retained_chars(
self : ProjectionSourceBudget,
chars : Int,
) -> Unit raise DocxError {
guard chars >= 0 && chars <= self.retained_chars_left else {
raise @core.docx_xml_resource_limit_error(DocxXmlMaterializedCharacters)
}
self.retained_chars_left -= chars
}
///|
fn ProjectionSourceBudget::charge_retained_token(
self : ProjectionSourceBudget,
) -> Unit raise DocxError {
guard self.retained_tokens_left > 0 else {
raise @core.docx_xml_resource_limit_error(DocxXmlTokens)
}
self.retained_tokens_left -= 1
}
///|
/// Precomputed fallback for a marker outside every projection node. When
/// `node_is_after` is true the marker is before `path`; otherwise it is after
/// the nearest preceding node.
priv struct StoryLevelMarkerPosition {
path : String
node_is_after : Bool
}
///|
/// The story-relative projection path ("p[3]", "tbl[1]/tr[1]/tc[1]").
fn ScannedNode::path(self : ScannedNode) -> String {
self.path
}
///|
/// Sequence number of the node's open tag.
fn ScannedNode::open_seq(self : ScannedNode) -> Int {
self.open_seq
}
///|
/// Sequence number of the node's close tag.
fn ScannedNode::close_seq(self : ScannedNode) -> Int {
self.close_seq
}
///|
fn canonical_physical_name(uri : String, local_name : String) -> String {
if is_wml_uri(uri) {
"w:\{local_name}"
} else if uri == MC_URI {
"mc:\{local_name}"
} else if uri == "" {
local_name
} else {
// Length-prefix arbitrary namespace URIs. Namespace names may legally
// contain every delimiter used by the human-readable ancestry syntax;
// the prefix keeps the concatenated path injective without depending on
// URI escaping rules.
"{\{uri.length()}:\{uri}}\{local_name}"
}
}
///|
fn nonnegative_decimal_digits(value : Int) -> Int {
let mut remaining = value
let mut digits = 1
while remaining >= 10 {
remaining /= 10
digits += 1
}
digits
}
///|
fn canonical_physical_name_chars(uri : String, local_name : String) -> Int {
if is_wml_uri(uri) {
2 + local_name.length()
} else if uri == MC_URI {
3 + local_name.length()
} else if uri == "" {
local_name.length()
} else {
// {uri-length:uri}local
3 +
nonnegative_decimal_digits(uri.length()) +
uri.length() +
local_name.length()
}
}
///|
/// Canonical physical path derived from identity-bearing parent links. Prefix
/// spelling never enters the path: namespace-equivalent Transitional/Strict
/// and alternate-prefix documents therefore describe the same tree shape.
#warnings("-unused_value")
fn ScannedElement::physical_path(
self : ScannedElement,
elements : Array[ScannedElement],
) -> String {
let ancestry : Array[Int] = []
let mut at = self.identity
while at >= 0 &&
at < elements.length() &&
ancestry.length() <= elements.length() {
ancestry.push(at)
at = elements[at].parent_index
}
let out = StringBuilder()
for index = ancestry.length() - 1; index >= 0; index = index - 1 {
let element = elements[ancestry[index]]
out.write_string(
"/\{canonical_physical_name(element.uri, element.local_name)}[\{element.same_name_ordinal}]",
)
}
out.to_string()
}
///|
/// Scanner output for one story part: nodes in CLOSE order (a node is
/// complete only at its close tag), markers in
/// document order, and non-fatal diagnostics.
priv struct StoryScan {
nodes : Array[ScannedNode]
elements : Array[ScannedElement]
markers : Array[ScannedMarker]
// Tracked-change containers in document order. Retained separately from
// markers: a revision is a range container, not a point, and it survives
// in stories the reader flattens (w:ins) or drops (w:del) entirely.
revisions : Array[ScannedRevision]
// Every tracked-change ELEMENT in the part, with byte extents, in document
// order. Never purged and never suppression-gated (see ScannedRevisionSite).
revision_sites : Array[ScannedRevisionSite]
warnings : Array[String]
story_level_positions : Map[Int, StoryLevelMarkerPosition]
// The story's ROOT element span (kind = its local name, path = "").
// L1 splices new definitions just before root.close_tag_start.
root : ScannedNode?
// Namespace URI that expanded the root element's QName. Kept separately
// from the canonicalized reader DOM so mutations can preserve Strict vs
// Transitional WML exactly.
root_namespace_uri : String?
// Private structural accounting used by the complexity regression tests.
// Every retraction step represents one checkpoint operation or one item
// discarded; no retained prefix is inspected.
work : AnnotationScanWork
}
///|
/// Structural work counters for the annotation scanner's adversarial paths.
/// Keep these counters in sync whenever those paths inspect another slot: the
/// white-box tests assert input-linear bounds rather than wall-clock timing.
priv struct AnnotationScanWork {
mut projection_opens : Int
mut emitted_nodes : Int
mut emitted_markers : Int
mut emitted_revisions : Int
mut ordering_steps : Int
mut story_position_steps : Int
mut retraction_calls : Int
mut retraction_steps : Int
}
///|
/// Scanner and verification paths are retained as UTF-16 strings. One shared
/// counter covers both copies across every story; limited transaction reads
/// additionally charge the caller's cumulative XML materialization budget.
priv struct AnnotationPathBudget {
shared_xml_budget : @xml.XmlReadBudget?
mut remaining_chars : Int
}
///|
let max_annotation_path_chars : Int = 8 * 1024 * 1024
///|
/// The scanner only consumes namespace bindings plus WML id/type/value
/// attributes. Those values are intrinsically small; bounding them before
/// UTF-8 decoding keeps this source-offset pass inside the transaction working
/// reserve even when an otherwise valid story carries a padded reference.
let max_annotation_relevant_attribute_bytes : Int = 4 * 1024
///|
fn AnnotationPathBudget::new(
shared_xml_budget : @xml.XmlReadBudget?,
max_chars? : Int = max_annotation_path_chars,
) -> AnnotationPathBudget {
{
shared_xml_budget,
remaining_chars: if max_chars > 0 {
max_chars
} else {
0
},
}
}
///|
fn AnnotationPathBudget::charge(
self : AnnotationPathBudget,
chars : Int,
) -> Unit raise DocxError {
if chars < 0 || chars > self.remaining_chars {
raise @core.docx_xml_resource_limit_error(DocxXmlMaterializedCharacters)
}
match self.shared_xml_budget {
Some(budget) => budget.charge_derived_chars(chars)
None => ()
}
self.remaining_chars -= chars
}
///|
fn decimal_char_count(value : Int) -> Int {
let mut remaining = value
let mut digits = 1
while remaining >= 10 {
remaining /= 10
digits += 1
}
digits
}
///|
fn AnnotationPathBudget::charge_descendant(
self : AnnotationPathBudget,
parent : String?,
kind : String,
ordinal : Int,
) -> Unit raise DocxError {
let parent_chars = match parent {
Some(path) => path.length() + 1
None => 0
}
self.charge(parent_chars + kind.length() + 2 + decimal_char_count(ordinal))
}
///|
/// The story's root-element span, when the part had one.
fn StoryScan::root(self : StoryScan) -> ScannedNode? {
self.root
}
///|
fn StoryScan::root_namespace_uri(self : StoryScan) -> String? {
self.root_namespace_uri
}
///|
/// Projection nodes, in close order (see the struct doc).
fn StoryScan::nodes(self : StoryScan) -> Array[ScannedNode] {
self.nodes
}
///|
#warnings("-unused_value")
fn StoryScan::elements(self : StoryScan) -> Array[ScannedElement] {
self.elements
}
///|
/// Annotation markers, in document order.
fn StoryScan::markers(self : StoryScan) -> Array[ScannedMarker] {
self.markers
}
///|
/// Tracked-change containers, in document order.
fn StoryScan::revisions(self : StoryScan) -> Array[ScannedRevision] {
self.revisions
}
///|
/// Every tracked-change ELEMENT with byte extents, in document order.
fn StoryScan::revision_sites(self : StoryScan) -> Array[ScannedRevisionSite] {
self.revision_sites
}
///|
/// Non-fatal scan diagnostics.
fn StoryScan::warnings(self : StoryScan) -> Array[String] {
self.warnings
}
///|
fn StoryScan::story_level_position(
self : StoryScan,
marker_seq : Int,
) -> StoryLevelMarkerPosition? {
self.story_level_positions.get(marker_seq)
}
///|
/// White-box complexity counters. Kept as one private tuple so production
/// callers cannot depend on instrumentation details.
fn StoryScan::complexity_counters(
self : StoryScan,
) -> (Int, Int, Int, Int, Int, Int, Int, Int) {
let work = self.work
(
work.projection_opens,
work.emitted_nodes,
work.emitted_markers,
work.emitted_revisions,
work.ordering_steps,
work.story_position_steps,
work.retraction_calls,
work.retraction_steps,
)
}
///|
/// One open element during the walk.
priv struct ScanFrame {
// Exact source QName plus resolved local name. Source-retaining mode
// compares the QName on close because namespace-equivalent but differently
// prefixed open/close tags are not well-formed XML.
qualified_name : String
local_name : String
is_wml : Bool
// Namespace prefixes THIS element declared (to pop on close).
declared : Array[String]
// Projection bookkeeping: Some(path) when this element is a projection
// node (kind = the PROJECTION kind, e.g. "note" for w:footnote — not
// the local name); child ordinal counters; last closed child.
mut projection_path : String?
projection_kind : String?
counters : StableStringMap[Int]
mut last_closed_child : String?
projection_open_order : Int
open_seq : Int
byte_start : Int
// Identity in StoryScan.elements when source-tree retention is enabled.
element_index : Int
// Raw source-tree sibling ordinals and links are deliberately separate from
// the reader-flattened projection counters above.
physical_counters : StableStringMap[Int]
mut last_element_child : Int
// Earliest interior insertion offset (just past the open tag, moved
// past a leading pPr when one closes directly inside this element).
mut content_start : Int
// TRANSPARENT containers (sdt/sdtContent/smartTag/ins) are flattened by
// the reader: their children count in the nearest non-transparent
// ancestor's counters, so projection ordinals mirror the parsed AST.
transparent : Bool
// SUPPRESSED subtrees (w:del content, plumbing notes) do not exist in
// the parsed AST: nothing inside gets a projection path. Mutable for
// vMerge retraction (tcPr precedes cell content, so flipping before
// any child opens is safe).
mut suppressed : Bool
// Where this frame's own ordinal was bumped, for vMerge retraction.
mut counter_owner : StableStringMap[Int]?
// Property containers (tcPr/pPr/rPr/...): the reader never surfaces
// markers from inside them.
in_properties : Bool
// Grid tracking mirroring the reader's calculate_typed_table_row_spans
// EXACTLY: origins are keyed by a cell's STARTING column only; every
// kept cell registers itself as the origin at its column; an unmatched
// continuation becomes an origin (fail-open); origins are never
// removed. tbl frames: the origin-column set. tr frames: the next
// cell's column (plus trPr/w:del row deletion, like read_table_row).
// tc frames: column/span (FIRST gridSpan via the reader's
// parse_grid_span) and the FIRST vMerge declaration; keep-or-retract
// decided at tcPr close.
origin_columns : Set[Int]
mut column_cursor : Int
grid_column : Int
mut grid_span : Int
mut grid_span_seen : Bool
mut vmerge_seen : Bool
mut vmerge_continue : Bool
mut row_deleted : Bool
// The reader honors only the FIRST tcPr/trPr (first_or_empty): a tc/tr
// frame records having seen one; a tcPr/trPr frame records whether it
// IS that first one (declarations from later ones are ignored).
mut properties_seen : Bool
first_properties : Bool
// Origin columns THIS row newly registered (absent before), so a
// late-deleted row can roll back exactly its own contributions —
// the reader excludes deleted rows BEFORE span calculation.
registered_columns : Array[Int]
// Exact output checkpoints at element open. Late tcPr/trPr decisions can
// discard the subtree with tail truncation instead of rescanning/copying
// everything accumulated earlier in the story.
node_checkpoint : Int
marker_checkpoint : Int
revision_checkpoint : Int
// Index into StoryScan.revision_sites when THIS element is a tracked-change
// element, else -1. Its closing tag fills in the site's closing offsets.
revision_site_index : Int
// Index into the enclosing site's name_edits when THIS element is a
// `w:delText`/`w:delInstrText`, else -1. Its closing tag fills in the
// record's closing-name range.
name_edit_index : Int
}
///|
fn is_property_container(uri : String, local_name : String) -> Bool {
is_wml_uri(uri) &&
local_name
is ("tcPr" | "pPr" | "rPr" | "tblPr" | "trPr" | "sectPr" | "tblGrid")
}
///|
fn projection_kind(uri : String, local_name : String) -> String? {
// DELIBERATE LIMIT (J1): image nodes are NOT projected. The reader
// emits zero..many Images per drawing (blip resolution needs the
// relationship graph) and wraps them in Hyperlink when a:hlinkClick is
// present — faithful mirroring is L0-scope work. Consequences, pinned:
// image-path coverage queries return empty, image byte spans arrive
// with L0, and markers inside drawings resolve to their RUN (correct).
if !is_wml_uri(uri) {
return None
}
match local_name {
"p" | "r" | "tbl" | "tr" | "tc" | "hyperlink" => Some(local_name)
// Container identity inside the notes/comments stories (these
// elements never occur in body/header/footer stories).
"footnote" | "endnote" => Some("note")
"comment" => Some("comment")
_ => None
}
}
///|
/// Containers the reader flattens into their parent flow.
fn is_transparent_container(uri : String, local_name : String) -> Bool {
(
is_wml_uri(uri) &&
local_name
is ("sdt" | "sdtContent" | "smartTag" | "ins" | "drawing" | "object")
) ||
(uri == MC_URI && local_name is ("AlternateContent" | "Fallback"))
}
///|
const MC_URI : String = "http://schemas.openxmlformats.org/markup-compatibility/2006"
///|
const TRANSITIONAL_OFFICE_RELATIONSHIPS_URI : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
///|
const STRICT_OFFICE_RELATIONSHIPS_URI : String = "http://purl.oclc.org/ooxml/officeDocument/relationships"
///|
/// XML Namespaces binds this prefix implicitly in every document. Keeping it
/// in the physical-identity table prevents `xml:x` from aliasing unqualified
/// `x` even when the source correctly omits an explicit declaration.
const XML_NAMESPACE_URI : String = "http://www.w3.org/XML/1998/namespace"
///|
const XMLNS_NAMESPACE_URI : String = "http://www.w3.org/2000/xmlns/"
///|
fn projection_source_is_xml_name_start(char : Char) -> Bool {
let code = char.to_int()
char.is_ascii_alphabetic() ||
char == ':' ||
char == '_' ||
(code >= 0xc0 && code <= 0xd6) ||
(code >= 0xd8 && code <= 0xf6) ||
(code >= 0xf8 && code <= 0x2ff) ||
(code >= 0x370 && code <= 0x37d) ||
(code >= 0x37f && code <= 0x1fff) ||
(code >= 0x200c && code <= 0x200d) ||
(code >= 0x2070 && code <= 0x218f) ||
(code >= 0x2c00 && code <= 0x2fef) ||
(code >= 0x3001 && code <= 0xd7ff) ||
(code >= 0xf900 && code <= 0xfdcf) ||
(code >= 0xfdf0 && code <= 0xfffd) ||
(code >= 0x10000 && code <= 0xeffff)
}
///|
fn projection_source_is_xml_name_char(char : Char) -> Bool {
let code = char.to_int()
projection_source_is_xml_name_start(char) ||
char == '-' ||
char == '.' ||
char.is_ascii_digit() ||
code == 0xb7 ||
(code >= 0x300 && code <= 0x36f) ||
(code >= 0x203f && code <= 0x2040)
}
///|
fn projection_source_is_xml_ncname(value : StringView) -> Bool {
let mut offset = 0
let mut first = true
while offset < value.length() {
guard value.get_char(offset) is Some(char) else { return false }
if char == ':' ||
(if first {
!projection_source_is_xml_name_start(char)
} else {
!projection_source_is_xml_name_char(char)
}) {
return false
}
first = false
offset += char.utf16_len()
}
!first
}
///|
fn projection_source_is_xml_qname(value : String) -> Bool {
match value.find(":") {
Some(colon) => {
let prefix = value[:colon]
let local_name = value[colon + 1:]
projection_source_is_xml_ncname(prefix) &&
projection_source_is_xml_ncname(local_name) &&
!local_name.contains(":")
}
None => projection_source_is_xml_ncname(value)
}
}
///|
fn validate_projection_source_namespace(
prefix : String,
namespace_uri : String,
) -> Unit raise DocxError {
for char in namespace_uri {
if char == ' ' || char == '\t' || char == '\n' || char == '\r' {
raise Unsupported(
message="the projection source scanner found whitespace in a namespace name",
)
}
}
if prefix == "xmlns" || namespace_uri == XMLNS_NAMESPACE_URI {
raise Unsupported(
message="the projection source scanner found an invalid xmlns namespace binding",
)
}
if prefix == "xml" {
if namespace_uri != XML_NAMESPACE_URI {
raise Unsupported(
message="the projection source scanner found an invalid xml namespace binding",
)
}
} else if namespace_uri == XML_NAMESPACE_URI {
raise Unsupported(
message="the projection source scanner found the reserved XML namespace on a non-xml prefix",
)
}
if prefix != "" && namespace_uri == "" {
raise Unsupported(
message="the projection source scanner found an undeclared namespace prefix",
)
}
}
///|
/// Subtrees the reader removes entirely.
fn is_suppressed_container(uri : String, local_name : String) -> Bool {
(is_wml_uri(uri) && local_name == "del") ||
(uri == MC_URI && local_name == "Choice")
}
///|
fn marker_kind(uri : String, local_name : String) -> ScanMarkerKind? {
if !is_wml_uri(uri) {
return None
}
match local_name {
"commentRangeStart" => Some(CommentStart)
"commentRangeEnd" => Some(CommentEnd)
"commentReference" => Some(CommentRef)
"footnoteReference" => Some(FootnoteRef)
"endnoteReference" => Some(EndnoteRef)
_ => None
}
}
///|
/// A tracked-change container the scanner records. `w:moveFrom`/`w:moveTo`
/// are deliberately out of scope: they are a paired MOVE, and reporting one
/// half of a move as a plain deletion would misdescribe the edit.
fn revision_kind(uri : String, local_name : String) -> ScanRevisionKind? {
if !is_wml_uri(uri) {
return None
}
match local_name {
"ins" => Some(Insertion)
"del" => Some(Deletion)
_ => None
}
}
///|
/// Every WML element that RECORDS a tracked change, whether or not the
/// resolver can act on it. Content insertions and deletions are the two the
/// resolver handles; the rest are recorded so a selection reaching one can
/// refuse loudly. Keeping the whole vocabulary in one place is what makes
/// "accept everything by Reviewer" honest: a construct missing from this list
/// would survive publication with no finding.
fn is_revision_site_name(uri : String, local_name : String) -> Bool {
if !is_wml_uri(uri) {
return false
}
local_name
is ("ins"
| "del"
// paired moves: resolving one half as a plain insertion or deletion
// would corrupt the other half, so they are recorded, never acted on
| "moveFrom"
| "moveTo"
| "moveFromRangeStart"
| "moveFromRangeEnd"
| "moveToRangeStart"
| "moveToRangeEnd"
// property revisions: these carry the PREVIOUS properties, and accepting
// or rejecting one rewrites a property container, not a content range
| "rPrChange"
| "pPrChange"
| "tblPrChange"
| "trPrChange"
| "tcPrChange"
| "sectPrChange"
| "tblGridChange"
| "tblPrExChange"
| "numberingChange"
// table-structure revisions
| "cellIns"
| "cellDel"
| "cellMerge"
// custom-XML range revisions
| "customXmlInsRangeStart"
| "customXmlInsRangeEnd"
| "customXmlDelRangeStart"
| "customXmlDelRangeEnd"
| "customXmlMoveFromRangeStart"
| "customXmlMoveFromRangeEnd"
| "customXmlMoveToRangeStart"
| "customXmlMoveToRangeEnd")
}
///|
/// The element name a deleted text run reverts to when its deletion is
/// rejected. `w:delText` is `w:t` and `w:delInstrText` is `w:instrText`;
/// nothing else is a deleted-text carrier.
fn deleted_text_replacement(uri : String, local_name : String) -> String? {
if !is_wml_uri(uri) {
return None
}
match local_name {
"delText" => Some("t")
"delInstrText" => Some("instrText")
_ => None
}
}
///|
/// The byte range of a tag's LOCAL NAME. `name_start` points at the first
/// byte of the QName (just past '<', or just past ''); the range returned
/// excludes any namespace prefix and its colon, so overwriting it renames the
/// element without disturbing its prefix binding.
fn tag_local_name_span(
part : BytesView,
name_start : Int,
limit : Int,
) -> (Int, Int) {
let mut at = name_start
while at < limit && !is_name_end(part[at]) {
at += 1
}
let mut local_start = name_start
for scan in name_start.. (String, String) {
match name.find(":") {
Some(colon) => (name[0:colon].to_owned(), name[colon + 1:].to_owned())
None => ("", name)
}
}
///|
fn scan_closing_name(
part : BytesView,
start : Int,
close_end : Int,
) -> String raise DocxError {
let mut at = start + 2
guard at < close_end && !is_xml_space(part[at]) else {
raise Unsupported(
message="the projection source scanner found whitespace after a closing-tag opener",
)
}
let name_start = at
while at < close_end && !is_name_end(part[at]) {
at += 1
}
guard at > name_start else {
raise Unsupported(
message="the projection source scanner found an empty closing tag",
)
}
guard at - name_start <= max_annotation_relevant_attribute_bytes else {
raise @core.docx_xml_resource_limit_error(DocxXmlTokenLength)
}
let name = utf8_slice(part, name_start, at)
guard projection_source_is_xml_qname(name) else {
raise Unsupported(
message="the projection source scanner found an invalid closing-tag QName",
)
}
while at < close_end - 1 && is_xml_space(part[at]) {
at += 1
}
guard at == close_end - 1 else {
raise Unsupported(
message="the projection source scanner found malformed closing-tag content",
)
}
name
}
///|
/// Scans one story part. `part` is the ORIGINAL part bytes (UTF-8).
fn scan_story(
part : BytesView,
path_budget? : AnnotationPathBudget,
warning_collector? : AnnotationWarningCollector,
source_budget? : ProjectionSourceBudget,
) -> StoryScan raise DocxError {
// A UTF-16 BOM means byte-level markup scanning would be wrong.
if part.length() >= 2 &&
(
(part[0] == 0xFF && part[1] == 0xFE) ||
(part[0] == 0xFE && part[1] == 0xFF)
) {
raise Unsupported(
message="the annotation scanner supports UTF-8 story parts only (found a UTF-16 byte-order mark)",
)
}
let nodes : Array[ScannedNode] = []
let elements : Array[ScannedElement] = []
let markers : Array[ScannedMarker] = []
let revisions : Array[ScannedRevision] = []
let revision_sites : Array[ScannedRevisionSite] = []
let owns_warning_collector = warning_collector is None
let warnings = match warning_collector {
Some(value) => value
None =>
AnnotationWarningCollector::new(
MAX_STORY_SCAN_WARNINGS,
MAX_ANNOTATION_WARNING_CHARS,
)
}
let mut root : ScannedNode? = None
let mut root_namespace_uri : String? = None
let frames : Array[ScanFrame] = []
let root_counters : StableStringMap[Int] = SortedMap([])
// Prefix -> stack of URIs (innermost last). "" is the default namespace;
// `xml` is the reserved implicit binding supplied by the XML standard.
let namespaces : StableStringMap[Array[String]] = SortedMap([
("xml", [XML_NAMESPACE_URI]),
])
let mut seq = 0
let mut projection_open_count = 0
let work = AnnotationScanWork::{
projection_opens: 0,
emitted_nodes: 0,
emitted_markers: 0,
emitted_revisions: 0,
ordering_steps: 0,
story_position_steps: 0,
retraction_calls: 0,
retraction_steps: 0,
}
let path_budget = match path_budget {
Some(value) => value
None => AnnotationPathBudget::new(None)
}
let length = part.length()
let mut index = 0
fn current_projection() -> ScanFrame? {
for at = frames.length() - 1; at >= 0; at = at - 1 {
if frames[at].projection_path is Some(_) {
return Some(frames[at])
}
}
None
}
fn skip_until(pattern : Bytes, from : Int) -> Int raise DocxError {
let mut at = from
while at + pattern.length() <= length {
let mut matched = true
for offset in 0..= length {
match source_budget {
Some(_) =>
raise Unsupported(
message="the projection source scanner found an unterminated markup opener",
)
None => break
}
}
let next = part[index + 1]
if next == b'?' {
index = skip_until(b"?>", index + 2)
continue
}
if next == b'!' {
if index + 3 < length &&
part[index + 2] == b'-' &&
part[index + 3] == b'-' {
index = skip_until(b"-->", index + 4)
} else if index + 8 < length &&
part[index + 2] == b'[' &&
part[index + 3] == b'C' {
index = skip_until(b"]]>", index + 9)
} else {
// DOCTYPE and friends: skip to the next '>' (stories don't carry
// internal subsets; L0's gate revisits).
index = skip_until(b">", index + 2)
}
continue
}
if next == b'/' {
// Closing tag.
let close_end = skip_until(b">", index + 2)
let closing_name = match source_budget {
Some(_) => Some(scan_closing_name(part, index, close_end))
None => None
}
match frames.pop() {
Some(frame) => {
match source_budget {
Some(budget) => {
let name = closing_name.unwrap_or("")
guard name == frame.qualified_name else {
raise Unsupported(
message="the projection source scanner found a mismatched closing tag",
)
}
guard frame.element_index >= 0 &&
frame.element_index < elements.length() else {
raise Unsupported(
message="the projection source scanner lost an element identity",
)
}
let element = elements[frame.element_index]
element.content_end = index
element.byte_end = close_end
if is_wml_uri(element.uri) &&
element.local_name is ("t" | "instrText") {
element.text_map = Some(
map_wt_content(
part,
element.content_start,
element.content_end,
budget.text_budget,
) catch {
TokenMapError(message) =>
raise Unsupported(
message="projection source scanner: \{message}",
)
},
)
}
}
None => ()
}
seq += 1
// Byte extents for the mutation-facing site records. Both are
// filled here, at the CLOSING tag, because that is the first
// moment either offset is known.
if frame.revision_site_index >= 0 &&
frame.revision_site_index < revision_sites.length() {
let site = revision_sites[frame.revision_site_index]
site.close_tag_start = index
site.byte_end = close_end
}
if frame.name_edit_index >= 0 {
match nearest_revision_site(frames, revision_sites) {
Some(site) =>
if frame.name_edit_index < site.name_edits.length() {
let record = site.name_edits[frame.name_edit_index]
let (local_start, local_end) = tag_local_name_span(
part,
index + 2,
close_end,
)
record.close_start = local_start
record.close_end = local_end
}
None => ()
}
}
for prefix in frame.declared {
match namespaces.get(prefix) {
Some(stack) => {
let _ = stack.pop()
}
None => ()
}
}
// A pPr closing directly inside a paragraph (or an rPr
// inside a run) moves the parent's earliest insertion offset
// past itself: CT_P and CT_R are SEQUENCES with the property
// container first, so content must land after it.
if frame.is_wml && frame.local_name == "pPr" {
match frames.last() {
Some(parent) if parent.is_wml && parent.local_name == "p" =>
parent.content_start = close_end
_ => ()
}
}
if frame.is_wml && frame.local_name == "rPr" {
match frames.last() {
Some(parent) if parent.is_wml && parent.local_name == "r" =>
parent.content_start = close_end
_ => ()
}
}
// vMerge DECISION at tcPr close (declarations final, before any
// cell content): reader-exact — a continuation whose STARTING
// column has a registered origin is merged away (retracted);
// an unmatched continuation is KEPT and becomes the origin.
if frame.is_wml &&
frame.local_name == "tcPr" &&
frame.first_properties {
match (frames.last(), nearest_frame_by_kind(frames, "tbl")) {
(Some(cell), Some(table)) =>
if cell.is_wml &&
cell.local_name == "tc" &&
cell.vmerge_continue &&
table.origin_columns.contains(cell.grid_column) {
retract_cell(cell, elements, nodes, markers, revisions, work)
}
_ => ()
}
}
// trPr/w:del deletes the whole row, reader-exact.
if frame.is_wml &&
frame.local_name == "trPr" &&
frame.first_properties {
match frames.last() {
Some(row) if row.is_wml && row.local_name == "tr" =>
if row.row_deleted {
retract_row(
row,
nearest_frame_by_kind(frames, "tbl"),
elements,
nodes,
markers,
revisions,
work,
)
}
_ => ()
}
}
// Close bookkeeping at tc close: EVERY kept cell registers as
// the origin at its starting column (origins are never
// removed — a later continuation merges into whatever cell is
// above it, exactly like the reader); the row cursor advances
// by the span (the reader's parse can yield zero or negative
// spans, mirrored verbatim).
if frame.is_wml && frame.local_name == "tc" {
if frame.element_index >= 0 &&
frame.element_index < elements.length() {
elements[frame.element_index].table_cell_vmerge_continue = frame.vmerge_continue
elements[frame.element_index].table_cell_grid_span = frame.grid_span
}
match
(
nearest_frame_by_kind(frames, "tr"),
nearest_frame_by_kind(frames, "tbl"),
) {
(Some(row), Some(table)) => {
if !frame.suppressed &&
!table.origin_columns.contains(frame.grid_column) {
table.origin_columns.add(frame.grid_column)
row.registered_columns.push(frame.grid_column)
}
row.column_cursor = frame.grid_column + frame.grid_span
}
_ => ()
}
}
finish_frame(frame, seq, index, close_end, nodes, frames, work)
if frames.length() == 0 && root is None {
root = Some({
path: "",
kind: frame.local_name,
open_order: -1,
open_seq: frame.open_seq,
close_seq: seq,
byte_start: frame.byte_start,
byte_end: close_end,
content_start: frame.content_start,
close_tag_start: index,
})
}
}
None =>
match source_budget {
Some(_) =>
raise Unsupported(
message="the projection source scanner found an unbalanced closing tag",
)
None => warnings.add("annotation scanner: unbalanced closing tag")
}
}
index = close_end
continue
}
// Opening (or empty) element. Source mode refuses before scan_tag
// materializes this element's QName or any retained attribute.
match source_budget {
Some(budget) => {
guard frames.length() > 0 || elements.length() == 0 else {
raise Unsupported(
message="the projection source scanner found multiple root elements",
)
}
budget.charge_element(frames.length() + 1)
}
None => ()
}
let (name, attributes, unretained_attribute_names, self_closing, tag_end) = scan_tag(
part, index, length, source_budget,
)
// Namespace declarations bind before resolving this element's name.
let declared : Array[String] = []
let declared_prefixes : StableStringMap[Bool] = SortedMap([])
for pair in attributes {
let (attr_name, attr_value) = pair
if attr_name == "xmlns" || attr_name.has_prefix("xmlns:") {
match source_budget {
// Charge the binding before allocating its prefix, scope-stack/map
// state, or closing-scope declaration entry.
Some(budget) => budget.charge_retained_token()
None => ()
}
let prefix = if attr_name == "xmlns" {
""
} else {
attr_name[6:].to_owned()
}
match source_budget {
Some(budget) => {
// The per-tag duplicate detector is another fixed-size retained
// entry during start-tag processing; charge it before insertion.
budget.charge_retained_token()
guard !declared_prefixes.contains(prefix) else {
raise Unsupported(
message="the projection source scanner found a duplicate namespace declaration",
)
}
validate_projection_source_namespace(prefix, attr_value)
declared_prefixes[prefix] = true
}
None => ()
}
match namespaces.get(prefix) {
Some(stack) => stack.push(attr_value)
None => namespaces[prefix] = [attr_value]
}
declared.push(prefix)
}
}
let (prefix, local_name) = split_qualified_name(name)
let uri = match namespaces.get(prefix) {
Some(stack) if stack.length() > 0 => stack[stack.length() - 1]
_ => {
guard source_budget is None || prefix == "" else {
raise Unsupported(
message="the projection source scanner found an unbound namespace prefix",
)
}
""
}
}
match source_budget {
Some(_) => {
for attr_name in unretained_attribute_names {
let (attr_prefix, _) = split_qualified_name(attr_name)
if attr_prefix != "" {
guard namespaces.get(attr_prefix) is Some(stack) &&
stack.length() > 0 else {
raise Unsupported(
message="the projection source scanner found an unbound attribute namespace prefix",
)
}
}
}
for pair in attributes {
let attr_name = pair.0
if attr_name != "xmlns" && !attr_name.has_prefix("xmlns:") {
let (attr_prefix, _) = split_qualified_name(attr_name)
if attr_prefix != "" {
guard namespaces.get(attr_prefix) is Some(stack) &&
stack.length() > 0 else {
raise Unsupported(
message="the projection source scanner found an unbound attribute namespace prefix",
)
}
}
}
}
}
None => ()
}
let element_index = match source_budget {
Some(budget) => {
let parent_index = match frames.last() {
Some(parent) => parent.element_index
None => -1
}
let same_name_ordinal = match frames.last() {
Some(parent) => {
// For arbitrary namespaces the canonical key contains the URI.
// Charge it before concatenation so a long URI repeated across
// many child names cannot amplify into unbounded retained keys.
budget.charge_retained_chars(
canonical_physical_name_chars(uri, local_name),
)
bump_counter(
parent.physical_counters,
physical_counter_key(uri, local_name),
)
}
None => 1
}
let identity = elements.length()
elements.push({
identity,
uri,
local_name,
same_name_ordinal,
field_char_type: if is_wml_uri(uri) && local_name == "fldChar" {
attribute_value(
attributes,
"fldCharType",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
symbol_font: if is_wml_uri(uri) && local_name == "sym" {
attribute_value(
attributes,
"font",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
symbol_char: if is_wml_uri(uri) && local_name == "sym" {
attribute_value(
attributes,
"char",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
break_type: if is_wml_uri(uri) && local_name == "br" {
attribute_value(
attributes,
"type",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
bookmark_name: if is_wml_uri(uri) && local_name == "bookmarkStart" {
attribute_value(
attributes,
"name",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
hyperlink_relationship_id: if is_wml_uri(uri) &&
local_name == "hyperlink" {
relationship_attribute_value(
attributes,
"id",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
hyperlink_anchor: if is_wml_uri(uri) && local_name == "hyperlink" {
attribute_value(
attributes,
"anchor",
namespaces,
prefer_last=source_budget is Some(_),
)
} else {
None
},
parent_index,
first_child_index: -1,
next_sibling_index: -1,
byte_start: index,
content_start: tag_end,
content_end: if self_closing {
tag_end
} else {
-1
},
byte_end: if self_closing {
tag_end
} else {
-1
},
text_map: None,
retracted_by_vmerge: false,
deleted_table_row: false,
table_cell_vmerge_continue: false,
table_cell_grid_span: 1,
})
match frames.last() {
Some(parent) => {
if parent.last_element_child >= 0 {
elements[parent.last_element_child].next_sibling_index = identity
} else {
elements[parent_index].first_child_index = identity
}
parent.last_element_child = identity
}
None => ()
}
// A self-closing w:t has no interior insertion point. Leave its map
// absent so later surgery cannot treat the byte after '/>' as text
// content; rewriting the whole empty-element tag is a later slice.
identity
}
None => -1
}
if frames.length() == 0 && root_namespace_uri is None {
root_namespace_uri = Some(uri)
}
seq += 1
let transparent = is_transparent_container(uri, local_name)
// A subtree is suppressed if the reader drops it: w:del content,
// plumbing notes (separator/continuationSeparator/continuationNotice
// never surface as user notes), or anything inside an already
// suppressed ancestor.
let inherited_suppression = match frames.last() {
Some(parent) => parent.suppressed
None => false
}
let plumbing_note = is_wml_uri(uri) &&
local_name is ("footnote" | "endnote") &&
attribute_value(
attributes,
"type",
namespaces,
prefer_last=source_budget is Some(_),
)
is (Some("separator")
| Some("continuationSeparator")
| Some("continuationNotice"))
let suppressed = inherited_suppression ||
is_suppressed_container(uri, local_name) ||
plumbing_note
// Markers respect suppression: the reader drops w:del content,
// mc:Choice branches, and plumbing notes wholesale — markers
// included. (A parent's suppression may have been set after its
// open — vMerge retraction — which this naturally observes.)
let inside_properties = match frames.last() {
Some(parent) => parent.in_properties
None => false
}
if !suppressed && !inside_properties {
match marker_kind(uri, local_name) {
Some(kind) => {
// Recorded whether self-closing or not (Word emits them
// self-closing; a non-empty form still marks its position).
seq += 1
let id = attribute_value(
attributes,
"id",
namespaces,
prefer_last=source_budget is Some(_),
)
let (container_path, after_child) = match current_projection() {
Some(frame) => (frame.projection_path, frame.last_closed_child)
None => (None, None)
}
markers.push({
kind,
id: id.unwrap_or(""),
id_was_present: id is Some(_),
seq,
container_path,
after_child,
})
work.emitted_markers += 1
if id is None {
warnings.add(
"annotation scanner: a \{kind} marker has no w:id attribute",
)
}
}
None => ()
}
}
// Tracked-change containers are located from the CONTAINER's own frame,
// because `w:del` suppresses everything inside itself: gating on
// `suppressed` (as markers do) would make every deletion invisible,
// which is exactly the blind spot this record exists to close. An
// `inherited_suppression` gate still applies, so a revision the reader
// drops wholesale (inside an unselected mc:Choice branch) stays out.
//
// DELIBERATE LIMIT: property-container revisions are not recorded.
// `w:rPr/w:ins` marks an inserted PARAGRAPH MARK and `w:trPr/w:del` a
// deleted table ROW; both are revisions of a property, not of content,
// and the row case is retracted from the projection anyway, leaving no
// path to name. Content revisions are the ones a review agent reads.
if !inherited_suppression && !inside_properties {
match revision_kind(uri, local_name) {
Some(kind) => {
let id = attribute_value(
attributes,
"id",
namespaces,
prefer_last=source_budget is Some(_),
)
let author = attribute_value(
attributes,
"author",
namespaces,
prefer_last=source_budget is Some(_),
)
let date = attribute_value(
attributes,
"date",
namespaces,
prefer_last=source_budget is Some(_),
)
// Charge the retained metadata before it is stored, so a story
// padded with revision attributes cannot outgrow the scanner's
// cumulative retention allowance.
path_budget.charge(
id.map(String::length).unwrap_or(0) +
author.map(String::length).unwrap_or(0) +
date.map(String::length).unwrap_or(0),
)
let container_path = match current_projection() {
Some(frame) => frame.projection_path
None => None
}
revisions.push({ kind, id, author, date, container_path })
work.emitted_revisions += 1
if author is None {
warnings.add(
"annotation scanner: a w:\{kind} revision has no w:author attribute",
)
}
}
None => ()
}
}
// The mutation-facing site record. Recorded for EVERY tracked-change
// element regardless of suppression or property context: the resolver
// must be able to see (and refuse on) constructs the reader's projection
// drops, or "accept everything" would quietly leave them behind.
let revision_site_index = if is_revision_site_name(uri, local_name) {
let id = attribute_value(
attributes,
"id",
namespaces,
prefer_last=source_budget is Some(_),
)
let author = attribute_value(
attributes,
"author",
namespaces,
prefer_last=source_budget is Some(_),
)
let date = attribute_value(
attributes,
"date",
namespaces,
prefer_last=source_budget is Some(_),
)
path_budget.charge(
local_name.length() +
id.map(String::length).unwrap_or(0) +
author.map(String::length).unwrap_or(0) +
date.map(String::length).unwrap_or(0),
)
let container_path = match current_projection() {
Some(frame) => frame.projection_path
None => None
}
revision_sites.push({
local_name,
// Only a CONTENT insertion or deletion is resolvable. The same
// element names inside a property container mark an inserted or
// deleted PARAGRAPH MARK (or table row), which is a different edit.
supported: local_name is ("ins" | "del") && !inside_properties,
id,
author,
date,
container_path,
byte_start: index,
content_start: if self_closing {
-1
} else {
tag_end
},
close_tag_start: -1,
byte_end: if self_closing {
tag_end
} else {
-1
},
declares_namespaces: !declared.is_empty(),
name_edits: [],
})
revision_sites.length() - 1
} else {
-1
}
// A deleted-text carrier records the byte range of its own name in both
// tags, so rejecting the enclosing deletion can rename it in place.
let name_edit_index = match deleted_text_replacement(uri, local_name) {
Some(replacement) =>
match nearest_revision_site(frames, revision_sites) {
Some(site) => {
path_budget.charge(replacement.length())
let (local_start, local_end) = tag_local_name_span(
part,
index + 1,
tag_end,
)
site.name_edits.push({
open_start: local_start,
open_end: local_end,
close_start: -1,
close_end: -1,
replacement,
})
site.name_edits.length() - 1
}
None => -1
}
None => -1
}
let mut counter_owner : StableStringMap[Int]? = None
let mut projection_kind_value : String? = None
let mut projection_open_order = -1
let projection = if suppressed {
None
} else {
match projection_kind(uri, local_name) {
Some(kind) => {
// Ordinals live on the nearest NON-transparent frame, mirroring
// the reader's flattening; root_counters backs the degenerate
// projection-at-root case.
let counters = nearest_counters(frames, root_counters)
let ordinal = bump_counter(counters, kind)
counter_owner = Some(counters)
projection_kind_value = Some(kind)
projection_open_order = projection_open_count
projection_open_count += 1
work.projection_opens += 1
let parent_path = match current_projection() {
Some(frame) => frame.projection_path
None => None
}
path_budget.charge_descendant(parent_path, kind, ordinal)
let path = match parent_path {
Some(base) => "\{base}/\{kind}[\{ordinal}]"
None => "\{kind}[\{ordinal}]"
}
Some(path)
}
None => None
}
}
// Grid declarations, DIRECT tcPr children only, mirroring the
// reader: FIRST vMerge wins (continue iff val absent or "continue");
// FIRST gridSpan wins, parsed with the reader's own parse_grid_span.
let parent_is = fn(name : String) -> Bool {
match frames.last() {
Some(parent) =>
parent.is_wml && parent.local_name == name && parent.first_properties
None => false
}
}
if is_wml_uri(uri) && local_name == "vMerge" && parent_is("tcPr") {
match nearest_frame_by_kind(frames, "tc") {
Some(cell) =>
if !cell.vmerge_seen {
cell.vmerge_seen = true
cell.vmerge_continue = attribute_value(
attributes,
"val",
namespaces,
prefer_last=source_budget is Some(_),
)
is (None | Some("continue"))
}
None =>
warnings.add("annotation scanner: a vMerge outside any table cell")
}
}
if is_wml_uri(uri) && local_name == "gridSpan" && parent_is("tcPr") {
match nearest_frame_by_kind(frames, "tc") {
Some(cell) =>
if !cell.grid_span_seen {
cell.grid_span_seen = true
cell.grid_span = parse_grid_span(
attribute_value(
attributes,
"val",
namespaces,
prefer_last=source_budget is Some(_),
).unwrap_or("1"),
)
}
None => ()
}
}
// trPr/w:del deletes the WHOLE ROW (reader's read_table_row).
if is_wml_uri(uri) && local_name == "del" && parent_is("trPr") {
match nearest_frame_by_kind(frames, "tr") {
Some(row) => row.row_deleted = true
None => ()
}
}
if self_closing {
// Open+close in place; only projection nodes matter for spans.
seq += 1
// A self-closing or still claims FIRST-
// properties status (the reader's first_or_empty picks it, so a
// later framed tcPr/trPr must be ignored). Its own declarations
// are vacuously empty and its close-decision is a no-op.
if is_wml_uri(uri) && local_name is ("tcPr" | "trPr") {
let owner_kind = if local_name == "tcPr" { "tc" } else { "tr" }
match frames.last() {
Some(owner) if owner.is_wml && owner.local_name == owner_kind =>
owner.properties_seen = true
_ => ()
}
}
// A self-closing is a normal span-1 cell to the reader:
// register its origin column and advance the row cursor.
if projection_kind_value is Some("tc") {
match
(
nearest_frame_by_kind(frames, "tr"),
nearest_frame_by_kind(frames, "tbl"),
) {
(Some(row), Some(table)) => {
if !table.origin_columns.contains(row.column_cursor) {
table.origin_columns.add(row.column_cursor)
row.registered_columns.push(row.column_cursor)
}
row.column_cursor = row.column_cursor + 1
}
_ => ()
}
}
match projection {
Some(path) => {
let kind = projection_kind_value.unwrap_or(local_name)
nodes.push({
path,
kind,
open_order: projection_open_order,
open_seq: seq - 1,
close_seq: seq,
byte_start: index,
byte_end: tag_end,
content_start: -1,
close_tag_start: -1,
})
work.emitted_nodes += 1
record_closed_child(frames, path)
}
None => ()
}
// A self-closing element at DEPTH ZERO is the story's (empty)
// root: record it so L1 can rewrite it by its own extent.
if frames.length() == 0 && root is None {
root = Some({
path: "",
kind: local_name,
open_order: -1,
open_seq: seq - 1,
close_seq: seq,
byte_start: index,
byte_end: tag_end,
content_start: -1,
close_tag_start: -1,
})
}
// A self-closing (or ) still pins its parent's
// content start past itself (pPr/rPr lead their sequences).
if is_wml_uri(uri) && local_name == "pPr" {
match frames.last() {
Some(parent) if parent.is_wml && parent.local_name == "p" =>
parent.content_start = tag_end
_ => ()
}
}
if is_wml_uri(uri) && local_name == "rPr" {
match frames.last() {
Some(parent) if parent.is_wml && parent.local_name == "r" =>
parent.content_start = tag_end
_ => ()
}
}
for prefix in declared {
match namespaces.get(prefix) {
Some(stack) => {
let _ = stack.pop()
}
None => ()
}
}
} else {
let in_properties = is_property_container(uri, local_name) ||
(match frames.last() {
Some(parent) => parent.in_properties
None => false
})
let grid_column = if projection_kind_value is Some("tc") {
// Claim this cell's grid column from the enclosing row.
match nearest_frame_by_kind(frames, "tr") {
Some(row) => row.column_cursor
None => -1
}
} else {
-1
}
// Is THIS element the first tcPr/trPr of its cell/row?
let first_properties = if is_wml_uri(uri) &&
local_name is ("tcPr" | "trPr") {
let owner_kind = if local_name == "tcPr" { "tc" } else { "tr" }
match frames.last() {
Some(owner) if owner.is_wml && owner.local_name == owner_kind =>
if owner.properties_seen {
false
} else {
owner.properties_seen = true
true
}
_ => false
}
} else {
false
}
frames.push({
qualified_name: name,
local_name,
is_wml: is_wml_uri(uri),
declared,
projection_path: projection,
projection_kind: projection_kind_value,
counters: SortedMap([]),
last_closed_child: None,
projection_open_order,
open_seq: seq,
byte_start: index,
element_index,
physical_counters: SortedMap([]),
last_element_child: -1,
content_start: tag_end,
transparent,
suppressed,
counter_owner,
in_properties,
origin_columns: Set([]),
column_cursor: 0,
grid_column,
grid_span: 1,
grid_span_seen: false,
vmerge_seen: false,
vmerge_continue: false,
row_deleted: false,
properties_seen: false,
first_properties,
registered_columns: [],
node_checkpoint: nodes.length(),
marker_checkpoint: markers.length(),
revision_checkpoint: revisions.length(),
revision_site_index,
name_edit_index,
})
}
index = tag_end
}
if frames.length() > 0 {
match source_budget {
Some(_) =>
raise Unsupported(
message="the projection source scanner found unbalanced open tags at end of part",
)
None =>
warnings.add("annotation scanner: unbalanced open tags at end of part")
}
}
let nodes_by_open = nodes_in_open_order(nodes, projection_open_count, work)
{
nodes,
elements,
markers,
revisions,
revision_sites,
warnings: if owns_warning_collector {
warnings.finish()
} else {
[]
},
story_level_positions: build_story_level_positions(
nodes, nodes_by_open, markers, work,
),
root,
root_namespace_uri,
work,
}
}
///|
/// Reconstructs surviving nodes in open order without sorting. Retractions can
/// leave holes in the scan-time ordinals, so fill exact slots and compact them
/// with a second monotonic pass. The slot array is bounded by projection opens.
fn nodes_in_open_order(
nodes : Array[ScannedNode],
projection_open_count : Int,
work : AnnotationScanWork,
) -> Array[ScannedNode] {
let slots : Array[ScannedNode?] = Array::make(projection_open_count, None)
work.ordering_steps += projection_open_count
for node in nodes {
work.ordering_steps += 1
if node.open_order >= 0 && node.open_order < slots.length() {
slots[node.open_order] = Some(node)
}
}
let ordered : Array[ScannedNode] = []
for slot in slots {
match slot {
Some(node) => ordered.push(node)
None => ()
}
}
ordered
}
///|
/// Computes every story-level marker's nearest following/preceding projection
/// node with two monotonic sweeps. This replaces one full node scan per marker.
fn build_story_level_positions(
nodes_by_close : Array[ScannedNode],
nodes_by_open : Array[ScannedNode],
markers : Array[ScannedMarker],
work : AnnotationScanWork,
) -> Map[Int, StoryLevelMarkerPosition] {
let positions : Map[Int, StoryLevelMarkerPosition] = Map([])
let mut open_at = 0
let mut close_at = 0
let mut nearest_before : ScannedNode? = None
// `markers` is emitted in document order, `nodes_by_open` follows the
// scan-time ordinal, and `nodes_by_close` is the scanner's native order.
// Each cursor only advances: no copies and no attacker-sized sort.
for marker in markers {
work.story_position_steps += 1
if marker.container_path is Some(_) {
continue
}
while open_at < nodes_by_open.length() &&
nodes_by_open[open_at].open_seq <= marker.seq {
open_at += 1
work.story_position_steps += 1
}
while close_at < nodes_by_close.length() &&
nodes_by_close[close_at].close_seq < marker.seq {
nearest_before = Some(nodes_by_close[close_at])
close_at += 1
work.story_position_steps += 1
}
if open_at < nodes_by_open.length() {
positions[marker.seq] = {
path: nodes_by_open[open_at].path,
node_is_after: true,
}
} else {
match nearest_before {
Some(node) =>
positions[marker.seq] = { path: node.path, node_is_after: false }
None => ()
}
}
}
positions
}
///|
/// The counters of the nearest non-transparent open frame (the reader
/// flattens transparent containers into their parent flow).
fn nearest_counters(
frames : Array[ScanFrame],
root_counters : StableStringMap[Int],
) -> StableStringMap[Int] {
for at = frames.length() - 1; at >= 0; at = at - 1 {
if !frames[at].transparent {
return frames[at].counters
}
}
root_counters
}
///|
/// The innermost open frame whose PROJECTION kind matches.
/// The innermost open DELETION site, or None when no ancestor is one.
/// Deleted-text carriers attach to the deletion that encloses them, so a
/// `w:delText` inside `` belongs to the inner `w:del`.
fn nearest_revision_site(
frames : Array[ScanFrame],
sites : Array[ScannedRevisionSite],
) -> ScannedRevisionSite? {
for at = frames.length() - 1; at >= 0; at = at - 1 {
let index = frames[at].revision_site_index
if index >= 0 && index < sites.length() && sites[index].local_name == "del" {
return Some(sites[index])
}
}
None
}
///|
fn nearest_frame_by_kind(
frames : Array[ScanFrame],
kind : String,
) -> ScanFrame? {
for at = frames.length() - 1; at >= 0; at = at - 1 {
if frames[at].projection_kind is Some(k) && k == kind {
return Some(frames[at])
}
// Also match retracted cells (projection cleared but still the
// structural tc frame).
if frames[at].is_wml && frames[at].local_name == kind {
return Some(frames[at])
}
}
None
}
///|
/// Removes already-emitted nodes/markers from inside a retracted frame:
/// the reader drops the WHOLE subtree even when properties arrive after
/// content (tolerated malformed input), so anything recorded since the
/// frame opened must go.
fn purge_subtree(
frame : ScanFrame,
nodes : Array[ScannedNode],
markers : Array[ScannedMarker],
revisions : Array[ScannedRevision],
work : AnnotationScanWork,
) -> Unit {
work.retraction_calls += 1
work.retraction_steps += 1 +
(nodes.length() - frame.node_checkpoint) +
(markers.length() - frame.marker_checkpoint) +
(revisions.length() - frame.revision_checkpoint)
nodes.truncate(frame.node_checkpoint)
markers.truncate(frame.marker_checkpoint)
// A retracted subtree has no projection node left to attach a revision
// path to, so its revisions go with it — the same rule markers follow.
revisions.truncate(frame.revision_checkpoint)
}
///|
/// Retracts a deleted row (trPr/w:del): un-counts its ordinal and
/// suppresses the whole subtree, like the reader's read_table_row.
fn retract_row(
row : ScanFrame,
table : ScanFrame?,
elements : Array[ScannedElement],
nodes : Array[ScannedNode],
markers : Array[ScannedMarker],
revisions : Array[ScannedRevision],
work : AnnotationScanWork,
) -> Unit {
if row.element_index >= 0 && row.element_index < elements.length() {
elements[row.element_index].deleted_table_row = true
}
match (row.projection_path, row.counter_owner) {
(Some(_), Some(counters)) => {
let current = counters.get_or_default("tr", 0)
if current > 0 {
counters["tr"] = current - 1
}
row.projection_path = None
row.counter_owner = None
row.suppressed = true
purge_subtree(row, nodes, markers, revisions, work)
// Roll back exactly the origins this row's cells introduced —
// the reader never lets a deleted row participate in merging.
match table {
Some(owner) =>
for column in row.registered_columns {
owner.origin_columns.remove(column)
}
None => ()
}
row.registered_columns.clear()
}
_ => ()
}
}
///|
/// Retracts a continuation cell the reader strips: un-counts its
/// ordinal and suppresses the whole subtree.
fn retract_cell(
cell : ScanFrame,
elements : Array[ScannedElement],
nodes : Array[ScannedNode],
markers : Array[ScannedMarker],
revisions : Array[ScannedRevision],
work : AnnotationScanWork,
) -> Unit {
match (cell.projection_path, cell.counter_owner) {
(Some(_), Some(counters)) => {
let current = counters.get_or_default("tc", 0)
if current > 0 {
counters["tc"] = current - 1
}
cell.projection_path = None
cell.counter_owner = None
cell.suppressed = true
if cell.element_index >= 0 && cell.element_index < elements.length() {
elements[cell.element_index].retracted_by_vmerge = true
}
purge_subtree(cell, nodes, markers, revisions, work)
}
_ => ()
}
}
///|
fn physical_counter_key(uri : String, local_name : String) -> String {
canonical_physical_name(uri, local_name)
}
///|
fn bump_counter(counters : StableStringMap[Int], kind : String) -> Int {
let next = counters.get_or_default(kind, 0) + 1
counters[kind] = next
next
}
///|
fn record_closed_child(frames : Array[ScanFrame], path : String) -> Unit {
// Boundary bookkeeping follows the FLATTENED structure too.
for at = frames.length() - 1; at >= 0; at = at - 1 {
if !frames[at].transparent {
frames[at].last_closed_child = Some(path)
return
}
}
}
///|
fn finish_frame(
frame : ScanFrame,
close_seq : Int,
close_tag_start : Int,
byte_end : Int,
nodes : Array[ScannedNode],
frames : Array[ScanFrame],
work : AnnotationScanWork,
) -> Unit {
match frame.projection_path {
Some(path) => {
nodes.push({
path,
kind: frame.projection_kind.unwrap_or(frame.local_name),
open_order: frame.projection_open_order,
open_seq: frame.open_seq,
close_seq,
byte_start: frame.byte_start,
byte_end,
content_start: frame.content_start,
close_tag_start,
})
work.emitted_nodes += 1
record_closed_child(frames, path)
}
None => ()
}
}
///|
/// Reads the qualified name and attributes of the tag opening at `start`
/// (which points at '<'). In source-retaining mode, attribute QNames that the
/// semantic scanner does not otherwise need are returned separately so their
/// namespace prefixes can still be validated. Returns (name, retained
/// attributes, unretained attribute names, self_closing, index-just-past '>').
fn scan_tag(
part : BytesView,
start : Int,
length : Int,
source_budget : ProjectionSourceBudget?,
) -> (String, Array[(String, String)], Array[String], Bool, Int) raise DocxError {
let mut at = start + 1
let name_start = at
while at < length && !is_name_end(part[at]) {
at += 1
}
match source_budget {
Some(budget) => {
guard at - name_start <= max_annotation_relevant_attribute_bytes else {
raise @core.docx_xml_resource_limit_error(DocxXmlTokenLength)
}
budget.charge_retained_chars(at - name_start)
}
None => ()
}
let name = utf8_slice(part, name_start, at)
match source_budget {
Some(_) => {
guard projection_source_is_xml_qname(name) else {
raise Unsupported(
message="the projection source scanner found an invalid element QName",
)
}
}
None => ()
}
let attributes : Array[(String, String)] = []
let unretained_attribute_names : Array[String] = []
let mut self_closing = false
while at < length {
// Skip whitespace.
let mut separated = false
while at < length && is_xml_space(part[at]) {
separated = true
at += 1
}
if at >= length {
break
}
if part[at] == b'>' {
at += 1
return (name, attributes, unretained_attribute_names, self_closing, at)
}
if part[at] == b'/' {
match source_budget {
Some(_) => {
guard at + 1 < length && part[at + 1] == b'>' else {
raise Unsupported(
message="the projection source scanner found malformed self-closing-tag content",
)
}
return (name, attributes, unretained_attribute_names, true, at + 2)
}
None => ()
}
self_closing = true
at += 1
continue
}
match source_budget {
Some(_) => {
guard separated else {
raise Unsupported(
message="the projection source scanner found attributes without separating whitespace",
)
}
}
None => ()
}
// Attribute name.
let attr_start = at
while at < length &&
part[at] != b'=' &&
!is_xml_space(part[at]) &&
part[at] != b'>' {
at += 1
}
let attr_end = at
let relevant_attribute = annotation_attribute_is_relevant(
part,
attr_start,
attr_end,
source_budget is Some(_),
)
let validated_source_name = match source_budget {
Some(budget) => {
guard attr_end - attr_start <= max_annotation_relevant_attribute_bytes else {
raise @core.docx_xml_resource_limit_error(DocxXmlTokenLength)
}
// Refuse before materializing every source attribute QName. Relevant
// names live in the retained attribute array; other names live just
// long enough for scan_story's post-declaration namespace check.
budget.charge_retained_token()
budget.charge_retained_chars(attr_end - attr_start)
let candidate = utf8_slice(part, attr_start, attr_end)
guard projection_source_is_xml_qname(candidate) else {
raise Unsupported(
message="the projection source scanner found an invalid attribute QName",
)
}
if !relevant_attribute {
unretained_attribute_names.push(candidate)
}
Some(candidate)
}
None => None
}
while at < length && is_xml_space(part[at]) {
at += 1
}
if at >= length || part[at] != b'=' {
match source_budget {
Some(_) =>
raise Unsupported(
message="the projection source scanner found a valueless attribute",
)
// The ordinary annotation scanner historically tolerates valueless
// attributes; source retention alone needs strict physical identity.
None => continue
}
}
at += 1
while at < length && is_xml_space(part[at]) {
at += 1
}
if at >= length || (part[at] != b'"' && part[at] != b'\'') {
raise Unsupported(
message="the annotation scanner found an unquoted attribute value",
)
}
let quote = part[at]
at += 1
let value_start = at
while at < length && part[at] != quote {
at += 1
}
if at >= length {
raise Unsupported(
message="the annotation scanner found an unterminated attribute value",
)
}
if relevant_attribute {
if attr_end - attr_start > max_annotation_relevant_attribute_bytes ||
at - value_start > max_annotation_relevant_attribute_bytes {
raise @core.docx_xml_resource_limit_error(DocxXmlTokenLength)
}
match source_budget {
Some(budget) =>
// The QName and its fixed-size retained slot were charged before the
// QName allocation above; only the value remains.
budget.charge_retained_chars(at - value_start)
None => ()
}
let attr_name = match validated_source_name {
Some(value) => value
None => utf8_slice(part, attr_start, attr_end)
}
let raw_value = @utf8.decode(part[value_start:at]) catch {
_ =>
raise Unsupported(
message="the annotation scanner found a non-UTF-8 attribute value",
)
}
// Attribute values must be entity-decoded: w:id="1" IS id "1"
// (and namespace URIs may legally carry &).
attributes.push((attr_name, decode_entities(raw_value)))
}
at += 1
}
raise Unsupported(message="the annotation scanner found an unterminated tag")
}
///|
fn bytes_range_equals_ascii(
part : BytesView,
start : Int,
end : Int,
expected : String,
) -> Bool {
if end - start != expected.length() {
return false
}
for offset in 0.. Bool {
if end - start < expected.length() {
return false
}
bytes_range_equals_ascii(part, start, start + expected.length(), expected)
}
///|
fn bytes_range_has_ascii_suffix(
part : BytesView,
start : Int,
end : Int,
expected : String,
) -> Bool {
if end - start < expected.length() {
return false
}
bytes_range_equals_ascii(part, end - expected.length(), end, expected)
}
///|
/// Filters before materialization. Namespace declarations are needed to
/// resolve names; tracked-change containers additionally need the CT_TrackChange
/// attributes that say who revised the document and when; source-retaining
/// reader projection additionally needs the few attributes that make BodyReader
/// emit zero versus one logical element.
fn annotation_attribute_is_relevant(
part : BytesView,
start : Int,
end : Int,
retain_projection_attributes : Bool,
) -> Bool {
bytes_range_equals_ascii(part, start, end, "xmlns") ||
bytes_range_has_ascii_prefix(part, start, end, "xmlns:") ||
bytes_range_has_ascii_suffix(part, start, end, ":id") ||
bytes_range_has_ascii_suffix(part, start, end, ":type") ||
bytes_range_has_ascii_suffix(part, start, end, ":val") ||
bytes_range_has_ascii_suffix(part, start, end, ":author") ||
bytes_range_has_ascii_suffix(part, start, end, ":date") ||
(
retain_projection_attributes &&
(
bytes_range_has_ascii_suffix(part, start, end, ":fldCharType") ||
bytes_range_has_ascii_suffix(part, start, end, ":font") ||
bytes_range_has_ascii_suffix(part, start, end, ":char") ||
bytes_range_has_ascii_suffix(part, start, end, ":name") ||
bytes_range_has_ascii_suffix(part, start, end, ":anchor")
)
)
}
///|
fn is_xml_space(byte : Byte) -> Bool {
byte == b' ' || byte == b'\t' || byte == b'\r' || byte == b'\n'
}
///|
fn is_name_end(byte : Byte) -> Bool {
is_xml_space(byte) || byte == b'>' || byte == b'/'
}
///|
fn utf8_slice(
part : BytesView,
start : Int,
end : Int,
) -> String raise DocxError {
@utf8.decode(part[start:end]) catch {
_ =>
raise Unsupported(
message="the annotation scanner found a non-UTF-8 XML name or value",
)
}
}
///|
/// The marker's id attribute is namespace-qualified (`w:id`): any prefix
/// bound to a WML URI matches; unprefixed attributes never do (default
/// namespaces do not apply to attributes). Legacy scans preserve their
/// historical first-match rule; source-retaining scans request the last
/// physical match so mixed Strict/Transitional input follows the XML reader's
/// canonical-map overwrite precedence.
fn attribute_value(
attributes : Array[(String, String)],
local_name : String,
namespaces : StableStringMap[Array[String]],
prefer_last? : Bool = false,
) -> String? {
let mut matched : String? = None
for pair in attributes {
let (name, value) = pair
match name.find(":") {
Some(colon) => {
let prefix = name[0:colon].to_owned()
let attr_local = name[colon + 1:].to_owned()
if attr_local == local_name {
match namespaces.get(prefix) {
Some(stack) if stack.length() > 0 &&
is_wml_uri(stack[stack.length() - 1]) => {
if !prefer_last {
return Some(value)
}
matched = Some(value)
}
_ => ()
}
}
}
// Unprefixed attributes are in NO namespace (default xmlns does not
// apply to attributes) — they never match a w: attribute.
None => ()
}
}
matched
}
///|
fn relationship_attribute_value(
attributes : Array[(String, String)],
local_name : String,
namespaces : StableStringMap[Array[String]],
prefer_last? : Bool = false,
) -> String? {
let mut matched : String? = None
for pair in attributes {
let (name, value) = pair
match name.find(":") {
Some(colon) => {
let prefix = name[0:colon].to_owned()
let attr_local = name[colon + 1:].to_owned()
if attr_local == local_name {
match namespaces.get(prefix) {
Some(stack) if stack.length() > 0 => {
let uri = stack[stack.length() - 1]
if uri == TRANSITIONAL_OFFICE_RELATIONSHIPS_URI ||
uri == STRICT_OFFICE_RELATIONSHIPS_URI {
if !prefer_last {
return Some(value)
}
matched = Some(value)
}
}
_ => ()
}
}
}
None => ()
}
}
matched
}
///|
/// Decodes the five XML predefined entities and numeric character
/// references. Malformed references are kept verbatim — @json-style
/// tolerance; the XML parser is the strict gate.
fn decode_entities(value : String) -> String {
if value.find("&") is None {
return value
}
let units = value.code_units()
let builder = StringBuilder::new()
let length = units.length()
let mut at = 0
while at < length {
let unit = units[at].to_int()
if unit != '&'.to_int() {
match unit.to_char() {
Some(ch) => builder.write_char(ch)
None =>
// Keep lone surrogates as-is via a paired write when possible.
match
(if at + 1 < length {
(unit, units[at + 1].to_int())
} else {
(unit, 0)
}) {
(high, low) if high >= 0xD800 &&
high <= 0xDBFF &&
low >= 0xDC00 &&
low <= 0xDFFF => {
match
(0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)).to_char() {
Some(ch) => builder.write_char(ch)
None => ()
}
at += 1
}
_ => ()
}
}
at += 1
continue
}
// Named entities have a fixed small vocabulary. Numeric references may be
// arbitrarily zero-padded, so scan their digit run without an artificial
// length ceiling. Stopping at the first non-digit keeps malformed tolerant
// input linear rather than repeatedly searching to a distant semicolon.
let numeric = at + 1 < length && units[at + 1].to_int() == '#'.to_int()
let mut numeric_base = 10
let mut digits_start = at + 2
if numeric &&
digits_start < length &&
(
units[digits_start].to_int() == 'x'.to_int() ||
units[digits_start].to_int() == 'X'.to_int()
) {
numeric_base = 16
digits_start += 1
}
let mut semi = -1
let mut probe = at + 1
while probe < length && (numeric || probe <= at + 24) {
if units[probe].to_int() == ';'.to_int() {
semi = probe
break
}
if numeric && probe >= digits_start {
let code = units[probe].to_int()
let digit = (code >= '0'.to_int() && code <= '9'.to_int()) ||
(
numeric_base == 16 &&
(
(code >= 'a'.to_int() && code <= 'f'.to_int()) ||
(code >= 'A'.to_int() && code <= 'F'.to_int())
)
)
if !digit {
break
}
}
probe += 1
}
if semi < 0 {
builder.write_char('&')
at += 1
continue
}
let name_builder = StringBuilder::new()
for i in (at + 1).. name_builder.write_char(ch)
None => ()
}
}
let entity = name_builder.to_string()
let decoded : Char? = match entity {
"amp" => Some('&')
"lt" => Some('<')
"gt" => Some('>')
"quot" => Some('"')
"apos" => Some('\'')
_ =>
if entity.has_prefix("#x") || entity.has_prefix("#X") {
parse_char_reference(entity[2:].to_owned(), 16)
} else if entity.has_prefix("#") {
parse_char_reference(entity[1:].to_owned(), 10)
} else {
None
}
}
match decoded {
Some(ch) => {
builder.write_char(ch)
at = semi + 1
}
None => {
builder.write_char('&')
at += 1
}
}
}
builder.to_string()
}
///|
fn parse_char_reference(digits : String, base : Int) -> Char? {
// Value-based, overflow-aware: leading-zero padding is valid XML
// (1 is "1"); anything past U+10FFFF is invalid however it
// is spelled.
if digits.length() == 0 {
return None
}
let mut value = 0
for ch in digits {
let digit = if ch is ('0'..='9') {
ch.to_int() - '0'.to_int()
} else if base == 16 && ch is ('a'..='f') {
ch.to_int() - 'a'.to_int() + 10
} else if base == 16 && ch is ('A'..='F') {
ch.to_int() - 'A'.to_int() + 10
} else {
return None
}
value = value * base + digit
if value > 0x10FFFF {
return None
}
}
value.to_char()
}
///|
#warnings("-unused_value")
fn scan_projection_source_tree(
part : BytesView,
budget? : ProjectionSourceBudget,
) -> StoryScan raise DocxError {
let budget = match budget {
Some(value) => value
None => projection_source_budget()
}
scan_story(part, source_budget=budget)
}
///|
fn scan_text(xml : String) -> StoryScan raise DocxError {
scan_story(@utf8.encode(xml))
}
///|
test "scanner: projection paths, marker containment, and document order" {
let scan = scan_text(
(
#|
#|
#|
#|plain
#|
#|
#|annotated
#|
#|
#|
#|cell
#|link
#|
#|
),
)
debug_inspect(scan.warnings().length(), content="0")
let paths : Array[String] = []
for node in scan.nodes() {
paths.push("\{node.kind} \{node.path()}")
}
paths.sort()
debug_inspect(
paths,
content=(
#|[
#| "p p[1]",
#| "p p[2]",
#| "p p[3]",
#| "tbl tbl[1]",
#| "r p[1]/r[1]",
#| "r p[2]/r[1]",
#| "r p[2]/r[2]",
#| "tr tbl[1]/tr[1]",
#| "tc tbl[1]/tr[1]/tc[1]",
#| "r p[3]/hyperlink[1]/r[1]",
#| "p tbl[1]/tr[1]/tc[1]/p[1]",
#| "hyperlink p[3]/hyperlink[1]",
#| "r tbl[1]/tr[1]/tc[1]/p[1]/r[1]",
#|]
),
)
let marks : Array[String] = []
for marker in scan.markers() {
marks.push(
"\{marker.kind()} id=\{marker.id()} in=\{marker.container_path().unwrap_or("")} after=\{marker.after_child().unwrap_or("-")}",
)
}
debug_inspect(
marks,
content=(
#|[
#| "commentRangeStart id=1 in=p[2] after=-",
#| "commentRangeEnd id=1 in=p[2] after=p[2]/r[1]",
#| "commentReference id=1 in=p[2]/r[2] after=-",
#| "footnoteReference id=2 in=p[3]/hyperlink[1]/r[1] after=-",
#|]
),
)
// Document order and interval sanity: the range start precedes r[1]'s
// open; the end follows r[1]'s close; the covered run's interval nests
// inside [start.seq, end.seq].
let markers = scan.markers()
let mut covered_open = 0
let mut covered_close = 0
for node in scan.nodes() {
if node.path() == "p[2]/r[1]" {
covered_open = node.open_seq()
covered_close = node.close_seq()
}
}
debug_inspect(markers[0].seq() < covered_open, content="true")
debug_inspect(covered_close < markers[1].seq(), content="true")
}
///|
test "scanner: tracked-change containers survive the reader's flattening" {
// The reader flattens w:ins into the accepted text and drops w:del
// wholesale, so neither reaches the parsed tree. The scan is the only
// place the revision identity exists.
let scan = scan_text(
(
#|
#|
#|The revenue was
#|flat
#|up 18%
#|
#|cell
#|
#|
),
)
let found : Array[String] = []
for revision in scan.revisions() {
found.push(
"\{revision.kind()} id=\{revision.id().unwrap_or("-")} author=\{revision.author().unwrap_or("-")} date=\{revision.date().unwrap_or("-")} in=\{revision.container_path().unwrap_or("")}",
)
}
// The dateless insertion reports "-", not a substituted timestamp: w:date
// is optional in CT_TrackChange and defaulting it would invent a fact.
debug_inspect(
found,
content=(
#|[
#| "del id=1 author=Reviewer date=2026-01-01T00:00:00Z in=p[1]",
#| "ins id=2 author=Reviewer date=- in=p[1]",
#| "ins id=3 author=Ravi date=- in=tbl[1]/tr[1]/tc[1]/p[1]",
#|]
),
)
debug_inspect(scan.warnings().length(), content="0")
}
///|
test "scanner: revisions the reader never renders are not reported" {
// An unselected mc:Choice branch is dropped by the reader, so a revision
// inside it describes text nobody reads. Property-container revisions
// (an inserted paragraph MARK, a deleted table ROW) are deliberately out
// of scope for the same reason markers are: they revise a property, not
// content, and the deleted row is retracted from the projection entirely.
let scan = scan_text(
(
#|
#|
#|a
#|xb
#|c
#|d
#|
#|
),
)
let found : Array[String] = []
for revision in scan.revisions() {
found.push("\{revision.kind()} id=\{revision.id().unwrap_or("-")}")
}
// Only the revision inside a rendered paragraph survives. id=4 was
// retracted with its deleted row; id=1 and id=3 are property revisions;
// id=2 sits in a branch the reader discards.
debug_inspect(
found,
content=(
#|["ins id=5"]
),
)
}
///|
test "scanner: a revision without an author is reported and diagnosed" {
let scan = scan_text(
(
#|
#|x
#|
),
)
guard scan.revisions() is [revision] else { fail("expected one revision") }
debug_inspect(revision.author(), content="None")
debug_inspect(
scan.warnings(),
content=(
#|["annotation scanner: a w:ins revision has no w:author attribute"]
),
)
}
///|
test "scanner: alternate namespace prefixes and non-WML lookalikes" {
// The WML URI bound to prefix `x`; a DIFFERENT namespace binds `w` —
// its commentRangeStart lookalike and its `w:id` must be ignored.
let scan = scan_text(
(
#|
#|
#|
#|
#|
#|hi
#|
#|
#|
#|
),
)
let marks : Array[String] = []
for marker in scan.markers() {
marks.push("\{marker.kind()} id=\{marker.id()}")
}
debug_inspect(
marks,
content=(
#|["commentRangeStart id=3", "commentRangeEnd id=3"]
),
)
debug_inspect(scan.nodes().length(), content="2")
}
///|
test "scanner: comments story counts each comment body separately" {
// w:comment is a projection CONTAINER (kind "comment") in the comments
// story, so paragraph paths carry the container identity and each
// comment's paragraphs count independently — review round 1 finding 3.
let scan = scan_text(
(
#|
#|ab
#|c
#|
),
)
let paragraph_paths : Array[String] = []
for node in scan.nodes() {
if node.kind == "p" {
paragraph_paths.push(node.path())
}
}
paragraph_paths.sort()
debug_inspect(
paragraph_paths,
content=(
#|["comment[1]/p[1]", "comment[1]/p[2]", "comment[2]/p[1]"]
),
)
}
///|
test "scanner: unterminated tag and UTF-16 parts fail closed" {
try {
let _ = scan_text(
"
inspect(
message.has_prefix("the annotation scanner found an unterminated"),
content="true",
)
err => fail("unexpected error: \{repr(err)}")
} noraise {
_ => fail("expected the scan to fail")
}
let utf16 : Bytes = b"\xFF\xFE<\x00a\x00>\x00"
try {
let _ = scan_story(utf16)
} catch {
Unsupported(message~) =>
inspect(
message.has_prefix("the annotation scanner supports UTF-8"),
content="true",
)
err => fail("unexpected error: \{repr(err)}")
} noraise {
_ => fail("expected the scan to fail")
}
}
///|
test "scanner review round 1: flattening parity — sdt, ins, del, vMerge" {
// The reader flattens sdt/sdtContent/smartTag/ins and drops w:del and
// vMerge continuation cells; scanner ordinals must mirror that.
let scan = scan_text(
(
#|
#|
#|a
#|insertedgone
#|
#|tallb
#|b2
#|
#|
#|
),
)
let paths : Array[String] = []
for node in scan.nodes() {
paths.push(node.path())
}
paths.sort()
// The sdt-wrapped paragraph is p[2] (NOT a nested p[1]); the inserted
// run is p[2]/r[1]; the deleted run has no path at all. Row 2's
// continuation cell is stripped, so its sibling is tc[1] in the AST —
// and the continuation cell's paragraph/run get no paths.
debug_inspect(
paths,
content=(
#|[
#| "p[1]",
#| "p[2]",
#| "tbl[1]",
#| "p[1]/r[1]",
#| "p[2]/r[1]",
#| "tbl[1]/tr[1]",
#| "tbl[1]/tr[2]",
#| "tbl[1]/tr[1]/tc[1]",
#| "tbl[1]/tr[1]/tc[2]",
#| "tbl[1]/tr[2]/tc[1]",
#| "tbl[1]/tr[1]/tc[1]/p[1]",
#| "tbl[1]/tr[1]/tc[2]/p[1]",
#| "tbl[1]/tr[2]/tc[1]/p[1]",
#| "tbl[1]/tr[1]/tc[1]/p[1]/r[1]",
#| "tbl[1]/tr[1]/tc[2]/p[1]/r[1]",
#| "tbl[1]/tr[2]/tc[1]/p[1]/r[1]",
#|]
),
)
}
///|
test "scanner review round 1: entity-decoded ids, Strict WML, CDATA immunity" {
// 1 IS id "1"; the Strict namespace URI is recognized; marker
// lookalikes inside CDATA are never counted.
let scan = scan_text(
(
#|
#|
#|]]>
#|
#|
),
)
let marks : Array[String] = []
for marker in scan.markers() {
marks.push("\{marker.kind()} id=\{marker.id()}")
}
debug_inspect(
marks,
content=(
#|[
#| "commentRangeStart id=1",
#| "commentRangeEnd id=1",
#| "commentReference id=1",
#|]
),
)
}
///|
test "scanner skips oversized irrelevant attributes before decoding" {
let padding = "0".repeat(32 * 1024)
let scan = scan_text(
"",
)
assert_eq(scan.markers().length(), 1)
assert_eq(scan.markers()[0].id(), "1")
}
///|
test "scanner bounds relevant attributes before decoding" {
let padding = "0".repeat(max_annotation_relevant_attribute_bytes)
try
ignore(
scan_text(
"",
),
)
catch {
ResourceLimit(limit~, message~) => {
debug_inspect(limit, content="DocxXmlTokenLength")
inspect(message, content="XML token length budget exceeded")
}
_ => fail("unexpected annotation scanner error")
} noraise {
_ => fail("expected the annotation attribute budget to reject the source")
}
}
///|
test "scanner review round 1: plumbing notes are suppressed, user notes counted" {
let scan = scan_text(
(
#|
#|
#|
#|
#|real
#|
),
)
let paths : Array[String] = []
for node in scan.nodes() {
paths.push(node.path())
}
paths.sort()
debug_inspect(
paths,
content=(
#|["note[1]", "note[1]/p[1]", "note[1]/p[1]/r[1]"]
),
)
}
///|
test "scanner review round 3: vMerge fail-open, property-container markers, projection kinds" {
// A continuation in the FIRST row has no merge to continue — the
// reader keeps the cell (fail-open) and so must the scanner. A marker
// inside tcPr never surfaces (the reader ignores property-container
// content). Note containers report the PROJECTION kind "note".
let scan = scan_text(
(
#|
#|
#|
#|orphan continuation, kept
#|tall
#|
#|
#|
#|
),
)
debug_inspect(scan.markers().length(), content="0")
let cells : Array[String] = []
for node in scan.nodes() {
if node.kind == "tc" {
cells.push(node.path())
}
}
cells.sort()
// Row 1's orphan continuation kept; row 3's true continuation gone.
debug_inspect(
cells,
content=(
#|["tbl[1]/tr[1]/tc[1]", "tbl[1]/tr[2]/tc[1]"]
),
)
let note_scan = scan_text(
(
#|
#|real
#|
),
)
let mut container_kind = ""
for node in note_scan.nodes() {
if node.path() == "note[1]" {
container_kind = node.kind
}
}
debug_inspect(
container_kind,
content=(
#|"note"
),
)
}
///|
test "scanner review round 4: reader-exact merge origins, spans, and row deletion" {
let scan = scan_text(
(
#|
#|
#|
#|orphan kept, becomes origin
#|second orphan merges away
#|span2 restart at col0
#|plain col0misaligned col1 continuation KEPT (origins key on start column)
#|deleted row
#|first vMerge wins: continuation, merges into col0 origin above
#|after self-closing cell
#|
#|
#|
),
)
let cells : Array[String] = []
for node in scan.nodes() {
if node.kind is ("tc" | "tr") {
cells.push("\{node.kind} \{node.path()}")
}
}
cells.sort()
// Rows: r1 orphan kept (tr[1]/tc[1]); r2 second orphan MERGED (tr[2]
// has no tc); r3 span-2 restart kept; r4 plain col0 + misaligned col1
// continuation BOTH kept; r5 deleted (no tr — later rows renumber);
// r6 (now tr[5]) first-vMerge-wins continuation MERGED into the col0
// origin (the r4 plain cell); r7 (tr[6]) self-closing tc + sibling.
debug_inspect(
cells,
content=(
#|[
#| "tr tbl[1]/tr[1]",
#| "tr tbl[1]/tr[2]",
#| "tr tbl[1]/tr[3]",
#| "tr tbl[1]/tr[4]",
#| "tr tbl[1]/tr[5]",
#| "tr tbl[1]/tr[6]",
#| "tc tbl[1]/tr[1]/tc[1]",
#| "tc tbl[1]/tr[3]/tc[1]",
#| "tc tbl[1]/tr[4]/tc[1]",
#| "tc tbl[1]/tr[4]/tc[2]",
#| "tc tbl[1]/tr[6]/tc[1]",
#| "tc tbl[1]/tr[6]/tc[2]",
#|]
),
)
// gridSpan parse parity with the reader (parse_grid_span): the span-2
// restart advanced the cursor so r3's would-be second cell (none) —
// pinned indirectly by r4's col assignments above.
}
///|
test "scanner review round 5: span-sensitive merging, first-tcPr-only, retraction purges" {
// Row 1: a span-"2abc" cell (reader's parse_grid_span -> 2) then a
// plain sibling — the sibling lands at column 2 ONLY if the span
// parsed as 2. Row 2: continuations at columns 0 and 2 — BOTH merge
// away iff the row-1 columns were registered at 0 and 2 (a span-1
// misparse would register the sibling at column 1 and leave the
// column-2 continuation kept). Row 3: a SECOND tcPr carrying vMerge
// must be ignored (reader honors only the first), so the cell is
// KEPT. Row 4: a first-tcPr continuation arriving AFTER content —
// the retraction purges the already-emitted paragraph and marker.
let scan = scan_text(
(
#|
#|
#|
#|span twoat col 2
#|
#|second tcPr ignored, kept
#|emitted then purged
#|
#|
#|
),
)
// Wait — row 2's second continuation sits at column 1 (cursor), not 2.
// The cursor claims columns in document order: cell 1 at col 0
// (span 1 default? no: vMerge only) then cell 2 at col 1. Column 1
// has NO origin (row 1 registered 0 and 2), so cell 2 is KEPT.
let cells : Array[String] = []
for node in scan.nodes() {
if node.kind == "tc" {
cells.push(node.path())
}
}
cells.sort()
debug_inspect(
cells,
content=(
#|[
#| "tbl[1]/tr[1]/tc[1]",
#| "tbl[1]/tr[1]/tc[2]",
#| "tbl[1]/tr[2]/tc[1]",
#| "tbl[1]/tr[3]/tc[1]",
#|]
),
)
// The purged row-4 cell left no paragraph, run, or marker behind.
// The row node itself survives (only the CELL was retracted), so the
// no-leak check targets the row's CHILDREN — round-6 review caught the
// earlier prefix matching the row and the pin asserting a tautology.
let mut leaked = false
for node in scan.nodes() {
if node.path().has_prefix("tbl[1]/tr[4]/") {
leaked = true
}
}
debug_inspect(leaked, content="false")
debug_inspect(scan.markers().length(), content="0")
}
///|
test "scanner review round 6: a deleted row rolls back its origin registrations" {
// Row 1 is deleted AFTER its cell closed (late first trPr/w:del) —
// its origin at column 0 must be rolled back, so row 2's orphan
// continuation is KEPT (the reader excludes deleted rows before span
// calculation). Row 3's continuation then merges into ROW 2's cell.
let scan = scan_text(
(
#|
#|
#|
#|closes before the row is deleted
#|orphan kept
#|
#|
#|
#|
),
)
let rows_and_cells : Array[String] = []
for node in scan.nodes() {
if node.kind is ("tr" | "tc") {
rows_and_cells.push("\{node.kind} \{node.path()}")
}
}
rows_and_cells.sort()
debug_inspect(
rows_and_cells,
content=(
#|["tr tbl[1]/tr[1]", "tr tbl[1]/tr[2]", "tc tbl[1]/tr[1]/tc[1]"]
),
)
}
///|
test "scanner complexity: alternating late row and cell retractions stay linear" {
let count = 128
let xml = StringBuilder()
xml.write_string(
"",
)
// Establish the column-zero origin required for each late vMerge
// continuation below to be retracted.
xml.write_string("")
for index in 0..",
)
// The first row-properties element likewise arrives after a completed
// cell and retracts the entire row subtree.
xml.write_string(
"",
)
}
xml.write_string("")
// Retained story-level markers exercise both monotonic nearest-node sweeps.
for index in 0..")
}
xml.write_string("")
let scan = scan_text(xml.to_string())
assert_eq(scan.markers().length(), count)
let retained = scan.markers()
match scan.story_level_position(retained[0].seq()) {
Some(position) => assert_eq(position.path, "p[1]")
None => fail("expected a position for the first story-level marker")
}
match scan.story_level_position(retained[count - 1].seq()) {
Some(position) => assert_eq(position.path, "p[128]")
None => fail("expected a position for the last story-level marker")
}
let (
projection_opens,
emitted_nodes,
emitted_markers,
emitted_revisions,
ordering_steps,
story_position_steps,
retraction_calls,
retraction_steps,
) = scan.complexity_counters()
assert_eq(retraction_calls, count * 2)
// Checkpoint truncation can discard each emitted item at most once. Any
// retained-prefix scan would violate this structural work bound.
assert_true(
retraction_steps <=
retraction_calls + emitted_nodes + emitted_markers + emitted_revisions,
)
// Open-order reconstruction visits one slot per projection open and each
// surviving node once; story fallback visits every retained marker and
// advances each node cursor at most once.
assert_eq(ordering_steps, projection_opens + scan.nodes().length())
assert_true(
story_position_steps <= scan.markers().length() + scan.nodes().length() * 2,
)
}