// 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 ' (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,
  )
}