Skip to main content

latexml_core/
document.rs

1pub mod helpers;
2pub mod resource;
3pub mod tag;
4
5use std::{
6  borrow::Cow,
7  collections::{BTreeSet, VecDeque},
8  fmt::Write as _,
9  rc::Rc,
10};
11
12use libxml::tree::{Document as XmlDoc, Namespace, Node, NodeType};
13use once_cell::sync::Lazy;
14use regex::Regex;
15use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
16
17use crate::{
18  BoxOps, Digested, DigestedData, Tbox, TexMode,
19  common::{
20    arena::{self, SymHashMap, SymStr},
21    error::{emit_error, emit_warn, *},
22    font::{FONT_TEXT_DEFAULT, Font},
23    locator::Locator,
24    model,
25    object::Object,
26    store::Stored,
27    xml::{self, XML_NS, XPath},
28  },
29  definition::FontDirective,
30  document::{
31    resource::Resource,
32    tag::{TagConstructionClosure, TagOptionName},
33  },
34  ligature::Ligature,
35  list::List,
36  pin, state,
37  util::radix::radix_alpha,
38};
39
40static HAS_NONSPACE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\S").unwrap());
41static ONLY_SPACE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s+$").unwrap());
42static DASHES_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\-\-+").unwrap());
43
44static NON_MERGEABLE_ATTRIBUTES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
45  HashSet::from_iter([
46    "about",
47    "aboutlabelref",
48    "aboutidref",
49    "resource",
50    "resourcelabelref",
51    "resourceidref",
52    "property",
53    "rel",
54    "rev",
55    "tyupeof",
56    "datatype",
57    "content",
58    "data",
59    "datamimetype",
60    "dataencoding",
61  ])
62});
63// When merging attributes of two nodes, some attributes should be combined
64// Merged space separated
65static MERGE_ATTRIBUTE_SPACEJOIN: Lazy<HashSet<&'static str>> =
66  Lazy::new(|| HashSet::from_iter(["class", "lists", "inlist", "labels"]));
67// Merged ";" separated
68static MERGE_ATTRIBUTE_SEMICOLONJOIN: Lazy<HashSet<&'static str>> =
69  Lazy::new(|| HashSet::from_iter(["cssstyle"]));
70// Summed lengths
71static MERGE_ATTRIBUTE_SUMLENGTH: Lazy<HashSet<&'static str>> = Lazy::new(|| {
72  HashSet::from_iter([
73    "xoffset",
74    "yoffset",
75    "lpadding",
76    "rpadding",
77    "xtranslate",
78    "ytranslate",
79  ])
80});
81
82pub static FONT_ELEMENT_NAME: &str = "ltx:text";
83pub static MATH_TOKEN_NAME: &str = "ltx:XMTok";
84pub static MATH_HINT_NAME: &str = "ltx:XMHint";
85
86pub struct Document {
87  pub document:                XmlDoc,
88  pub pending:                 Vec<Node>,
89  context:                     Option<XPath>,
90  pub node:                    Node,
91  pub node_boxes:              HashMap<usize, Digested>, // used to be _box attribute
92  pub node_fonts:              HashMap<u64, Font>,       // used to be _font attribute
93  pub idstore:                 HashMap<String, Node>,
94  /// Streaming pass 1: every `xml:id` moved out of `idstore` by a spill.
95  /// The GLOBAL half of id-collision dedup — a later build-time minting of
96  /// the same id must rename exactly as eager would have (witness: the
97  /// 131 MB book restarts chapter numbering per part, and the second `Ch1`
98  /// collided only with a SPILLED chapter).
99  pub spilled_ids:             rustc_hash::FxHashSet<String>,
100  // the rewrite labels used to be in each rewrite rule, but they make more sense in doc
101  pub rewrite_labels:          HashMap<String, String>,
102  /// Document-wide labels SHARED by every streaming pass-2 fragment, consulted
103  /// only when `rewrite_labels` misses. Pass 2 used to copy the whole spilled
104  /// index into each fragment's own map, which is quadratic in document size:
105  /// 28,068 labels × 459,579 segments on the 131 MB witness = 12.9 billion
106  /// String allocations. Frag-local labels still win, preserving exactly the
107  /// `entry().or_insert_with()` precedence that copy had.
108  pub rewrite_labels_shared:   Option<Rc<HashMap<String, String>>>,
109  // the following are internal "local"-based declarations in Perl
110  localized_constructed_nodes: Vec<Vec<Node>>,
111  constructed_nodes:           Vec<Node>,
112  /// Free-list of emptied `Vec<Node>` buffers, reused across `init_constructed_nodes`
113  /// / `close_constructed_nodes` cycles. The per-body constructed-nodes frame pushes
114  /// here after its elements are drained so the next `init_constructed_nodes` can pop
115  /// a buffer with pre-existing capacity instead of heap-allocating a fresh one.
116  /// Cuts the `Vec<Node>::from_iter` hotspot that dominated absorb profiles.
117  reusable_node_buffers:       Vec<Vec<Node>>,
118  localized_boxes:             Vec<Option<Digested>>,
119  box_to_absorb:               Option<Digested>, // local $LaTeXML::BOX;
120  /// Transient handoff from `open_text`'s verbatim-space exception to
121  /// `open_text_internal`: whitespace-only TYPEWRITER text (verbatim
122  /// indentation / space-only verbatim lines) must be INSERTED even where
123  /// the current node needs an auto-open to reach a `#PCDATA` context —
124  /// both whitespace gates would otherwise drop it. Consumed (reset) by
125  /// `open_text_internal`.
126  verbatim_space_pending:      bool,
127  /// Source-map (`--source-map`) cache: the current `box_to_absorb`'s
128  /// source range, captured as a plain `Copy` `Locator` at set time so
129  /// stamping never re-borrows the box's `RefCell` mid-absorb (which
130  /// panics for the mutably-borrowed `Alignment` path). Mirrors the
131  /// `box_to_absorb` save stack; `None` when source-map is off.
132  current_box_locator:         Option<Locator>,
133  localized_box_locators:      Vec<Option<Locator>>,
134  localized_fonts:             Vec<Rc<Font>>,
135  /// Streaming mode only: the store holding processed spilled segments. Set
136  /// by the streaming driver after pass 2; `serialize_into` then splices each
137  /// segment's text where its `<_spilled_ ref="N"/>` placeholder sits. `None`
138  /// on the eager path and on fragment documents.
139  spill_store:                 Option<crate::sxml::SegmentStore>,
140  /// Streaming only: RDFa prefixes USED inside spilled (freed) content,
141  /// recorded at spill time — `set_rdfa_prefixes` scans the live DOM, and
142  /// spilled usages would otherwise vanish from the root's `prefix=`.
143  extra_rdfa_prefixes:         Vec<String>,
144  /// Streaming only: an UNRESOLVED `label:`/`id:` rewrite scope makes the
145  /// rule INERT instead of continuing unscoped. Perl (and the eager path)
146  /// continue with the remaining clauses on the same tree when a scope fails
147  /// to resolve — harmless there, because in a whole document a
148  /// `scope=section` id always resolves. In a fragment (or on the spine,
149  /// where sections are spilled placeholders) "not here" usually means "in
150  /// another fragment", and continuing unscoped applies the rule to
151  /// EVERYTHING — sweep witness tests/math/declare.tex, where section-7
152  /// declarations stamped section-1 math.
153  pub scoped_rules_strict:     bool,
154  /// Streaming: serialize spill placeholders LITERALLY (`<_spilled_ ref=…/>`)
155  /// instead of splicing the segment text. True during pass 1 (a spilling
156  /// ancestor must keep its children's placeholders — inlining them rebuilt
157  /// multi-GB segments: an 841 MB and a 1.85 GB one on the 131 MB witness,
158  /// and pass 2 died re-parsing them) and on pass-2 fragment docs (which
159  /// re-emit the placeholders they contain). False only at final assembly,
160  /// where the splice resolves placeholders RECURSIVELY.
161  pub literal_placeholders:    bool,
162  /// Serialize spill segments FLAT — no indentation, no decorative newlines.
163  ///
164  /// A spilled segment's text is an intermediate: pass 2 re-parses it,
165  /// processes it, and re-serializes with `serialize_aux` at the recorded
166  /// depth, and only THAT text reaches the output. The indentation pass 1 used
167  /// to emit was therefore generated, written to disk, read back, materialized
168  /// as libxml2 text nodes, deleted again by `strip_indentation_whitespace`,
169  /// and finally regenerated — pure round-trip tax.
170  ///
171  /// Measured on the 131 MB witness: **51.2% of serialized bytes are leading
172  /// whitespace + newlines** (189.4 MB of 381.5 MB sampled, 6,092,937 lines).
173  /// Over ~2.45 GB of segment text that is ~1.2 GB generated and written, read
174  /// back, and ~40M text nodes allocated in pass 2's parse — which
175  /// `strip_indentation_whitespace` then `unlink_node`s, and unlink does not
176  /// free, so they are orphaned for the fragment's lifetime.
177  ///
178  /// Set for pass 1 only. Fragment documents in pass 2 are separate `Document`s
179  /// and default to `false`, so the OUTPUT keeps its formatting exactly.
180  pub spill_flat:              bool,
181  /// Streaming pass 2 only: the fragment's ancestor `xml:id`s at spill time
182  /// (SegmentMeta::ancestors). A `label:`/`id:`-scoped rewrite whose scope
183  /// resolves to one of these covers the WHOLE fragment.
184  pub fragment_ancestor_ids:   rustc_hash::FxHashSet<String>,
185  /// Streaming pass 2 only: the recorded qname of the fragment's REAL parent
186  /// (SegmentMeta::parent). `finalize_rec` substitutes it for the
187  /// `ltx:_lxfragment` parse wrapper in schema decisions, so top-level
188  /// fragment content is judged against the element it will splice back
189  /// under (e.g. the empty-`ltx:text` collapse needs
190  /// `can_contain(parent, grandchild)`).
191  pub fragment_parent_qname:   Option<SymStr>,
192  /// Streaming pass 1 only: suppress the ROOT element's `after_open` hook
193  /// dispatch. Eager semantics guarantee every hook runs with digestion
194  /// COMPLETE (build starts after digestion ends); interleaving would fire
195  /// the root's hooks during fragment 1 instead — with harmful effects, not
196  /// just missed data: the frontmatter hook (`base_utilities.rs`,
197  /// `Tag!("ltx:document", after_open_late)`) would run `insert_frontmatter`
198  /// against still-EMPTY frontmatter state and mark it done, discarding the
199  /// document's abstract when the real content arrived. The streaming driver
200  /// dispatches the root's hooks exactly once, after digestion finishes.
201  defer_root_after_open:       bool,
202}
203impl Default for Document {
204  fn default() -> Self { Self::new() }
205}
206
207/// Number of subtree runs spilled to disk this conversion (streaming mode;
208/// telemetry + test probe). Thread-local like the engine itself.
209#[thread_local]
210static SPILLED_SEGMENTS: std::cell::Cell<usize> = std::cell::Cell::new(0);
211
212/// How many segment runs the current conversion has spilled (0 = eager).
213pub fn spilled_segment_count() -> usize { SPILLED_SEGMENTS.get() }
214
215/// Reset the spill probe (the streaming driver calls this at conversion
216/// start; eager conversions never touch it).
217pub fn reset_spilled_segment_count() { SPILLED_SEGMENTS.set(0); }
218
219/// Element names whose closed instances may be spilled from ROOT level.
220/// Leading non-sectional root children (title, creators, abstract, resources)
221/// must stay live: the frontmatter fallback and `maybe_promote_leading_title`
222/// operate on them at end-of-build (`base_utilities.rs`).
223const ROOT_SPILLABLE: &[&str] = &[
224  "section",
225  "chapter",
226  "part",
227  "appendix",
228  "subsection",
229  "subsubsection",
230  "paragraph",
231  "subparagraph",
232  "index",
233  "glossary",
234  "acknowledgements",
235  "pagination",
236];
237
238/// The placeholder element left in the live DOM where a spilled run sat.
239/// Never serialized: `serialize_into` splices the processed segment instead.
240pub(crate) const SPILL_PLACEHOLDER: &str = "_spilled_";
241impl Object for Document {
242  fn get_locator(&self) -> Option<Locator> {
243    self
244      .get_node_box(&self.node)
245      .and_then(|tbox| tbox.get_locator())
246  }
247}
248
249/// Attachment policy for `Document::add_comment`. Kept module-local.
250enum Placement_ {
251  AppendChild,
252  PrevSibling,
253}
254
255impl Document {
256  pub fn new() -> Self {
257    crate::ensure_libxml_init(); // Thread-safe libxml2 initialization
258    // Node-mutation aliasing is enforced by libxml's `Node::node_ptr_mut`
259    // (`RefCell::try_borrow_mut`, libxml >= 0.3.14): it rejects only an *active*
260    // re-entrant borrow, not a high `Rc::strong_count` from benign clones. The
261    // former `set_node_rc_guard(8192)` band-aid — which raised a clone-count
262    // threshold to stop spurious "shared Node" errors on deeply-shared docs
263    // (e.g. arxiv 0805.2376 dcpic) — is no longer needed. See WISDOM.md #46.
264    let doc_scaffold = XmlDoc::new().unwrap();
265    let root = match doc_scaffold.get_root_element() {
266      Some(root) => root,
267      None => doc_scaffold.as_node(), // when empty, set the document node as a node.
268    };
269    Document {
270      document:                    doc_scaffold,
271      node:                        root,
272      node_boxes:                  HashMap::default(),
273      node_fonts:                  HashMap::default(),
274      idstore:                     HashMap::default(),
275      spilled_ids:                 rustc_hash::FxHashSet::default(),
276      rewrite_labels:              HashMap::default(),
277      rewrite_labels_shared:       None,
278      pending:                     Vec::new(),
279      localized_constructed_nodes: Vec::new(),
280      constructed_nodes:           Vec::new(),
281      reusable_node_buffers:       Vec::new(),
282      box_to_absorb:               None,
283      verbatim_space_pending:      false,
284      current_box_locator:         None,
285      localized_box_locators:      Vec::new(),
286      context:                     None,
287      localized_boxes:             Vec::new(),
288      localized_fonts:             Vec::new(),
289      spill_store:                 None,
290      extra_rdfa_prefixes:         Vec::new(),
291      scoped_rules_strict:         false,
292      literal_placeholders:        false,
293      spill_flat:                  false,
294      fragment_ancestor_ids:       rustc_hash::FxHashSet::default(),
295      fragment_parent_qname:       None,
296      defer_root_after_open:       false,
297    }
298  }
299
300  /// Wrap an existing XML document (a streamed fragment from
301  /// [`crate::sxml::FragmentReader`]) as a `Document`, so the pass-2 phases —
302  /// rewrites, math parsing, per-fragment finalize — run on it with the same
303  /// machinery the eager path uses. The insertion point starts at the root;
304  /// the idstore is rebuilt from the fragment's own `xml:id`s; `node_fonts`
305  /// is seeded by the caller (the hash→Font table is conversion-global and
306  /// pointer-free, so sharing its contents is safe).
307  pub fn from_xml_document(doc: XmlDoc, node_fonts: HashMap<u64, Font>) -> Result<Self> {
308    crate::ensure_libxml_init();
309    let node = match doc.get_root_element() {
310      Some(root) => root,
311      None => doc.as_node(),
312    };
313    let mut document = Document {
314      document: doc,
315      node,
316      node_fonts,
317      ..Document::new_empty_fields()
318    };
319    if let Some(root) = document.document.get_root_element() {
320      document.record_node_ids(&root)?;
321    }
322    Ok(document)
323  }
324
325  /// The all-empty field set shared by [`Document::new`] and
326  /// [`Document::from_xml_document`] (functional-update base; the caller
327  /// overrides `document`/`node`/`node_fonts`). Not public: an empty scaffold
328  /// with a dangling default `node` is not a usable document on its own.
329  fn new_empty_fields() -> Self { Self::new() }
330
331  /// Get the element at (or containing) the current insertion point.
332  pub fn get_element(&self) -> Option<Node> {
333    let mut node = &self.node;
334    let parent = node.get_parent();
335    if node.get_type() == Some(NodeType::TextNode) {
336      node = parent.as_ref().unwrap();
337    }
338    let final_type = node.get_type();
339    if final_type.is_none() || final_type == Some(NodeType::DocumentNode) {
340      None
341    } else {
342      Some(node.clone())
343    }
344  }
345
346  /// Find the nodes according to the given `xpath` expression,
347  /// the xpath is relative to $node (if given), otherwise to the document node.
348  pub fn findnodes(&mut self, xpath: &str, node_opt: Option<&Node>) -> Vec<Node> {
349    let node = match node_opt {
350      Some(node) => Cow::Borrowed(node),
351      None => match self.document.get_root_element() {
352        Some(root) => Cow::Owned(root),
353        None => return Vec::new(),
354      },
355    };
356    self.get_xpath().findnodes(xpath, Some(&node))
357  }
358
359  /// Get an XPath context that knows about our namespace mappings.
360  pub fn get_xpath(&mut self) -> &mut XPath {
361    if let Some(ref mut ctxt) = self.context {
362      ctxt
363    } else {
364      let mut context = XPath::new(&self.document, HashMap::default());
365      model::with_code_namespaces(|code_ns| {
366        for (prefix, ns) in code_ns {
367          // TODO: Is this too slow? We may need to store an active context in the state as an
368          // alternative
369          arena::with2(*prefix, *ns, |p_str, ns_str| {
370            context
371              .register_namespace(p_str, ns_str)
372              .expect("register_namespace has no reason to fail during get_xpath?");
373          });
374        }
375      });
376      self.context = Some(context);
377      self.context.as_mut().unwrap()
378    }
379  }
380
381  /// Like findnodes, but only returns the first matched node
382  pub fn findnode(&mut self, xpath: &str, node: Option<&Node>) -> Option<Node> {
383    let mut nodes = self.get_xpath().findnodes(xpath, node);
384    if nodes.is_empty() {
385      None
386    } else {
387      Some(nodes.remove(0))
388    }
389  }
390
391  /// Like findnodes, but expects an xpath that evaluates to a literal value (e.g. for attributes)
392  pub fn findvalues(&mut self, xpath: &str, node_opt: Option<&Node>) -> Vec<String> {
393    match node_opt {
394      Some(node) => self.get_xpath().findvalues(xpath, Some(node)),
395      None => match self.document.get_root_element() {
396        Some(root) => self.get_xpath().findvalues(xpath, Some(&root)),
397        _ => Vec::new(),
398      },
399    }
400  }
401
402  /// The current insertion point: the node that absorbed content is appended
403  /// to, and the one [`open_element`](Self::open_element) opens beneath.
404  /// Perl's `$document->getNode`.
405  pub fn get_node(&self) -> &Node { &self.node }
406  pub fn get_node_mut(&mut self) -> &mut Node { &mut self.node }
407
408  pub fn get_document(&self) -> &XmlDoc { &self.document }
409  pub fn get_document_mut(&mut self) -> &mut XmlDoc { &mut self.document }
410
411  // **********************************************************************
412  // This should be called before returning the final XML::LibXML::Document to the
413  // outside world.  It resolves the fonts for each node relative to it's
414  // ancestors. It removes the `helper' attributes that store fonts, source
415  // box, etc.
416  pub fn finalize(&mut self) -> Result<()> {
417    // Belt-and-suspenders idstore rebuild before prune_xmduals.
418    // Originally guarded a SIGSEGV on arxiv 1605.08055 where
419    // `mark_xmnode_visibility` dereferenced dangling lookup_id
420    // entries while recursing through XMRef nodes. Cycle 72 audited
421    // the 5 hazard call sites the earlier comment listed (math-parser
422    // `replace_tree` at parser.rs:456/690, `unbind_node` loops at
423    // parser.rs:639/856 and rewrite.rs:522); all 5 now have proper
424    // unrecord_node_ids / remove_node-cascade coverage. The rebuild
425    // call is retained as a safety net pending empirical
426    // 10k-sandbox verification on 1605.08055 — see SYNC_STATUS.md
427    // D3b [~] entry. A fresh DOM walk drops any surviving dangling
428    // entries; duplicates in DOM get modify_id via record_node_ids.
429    self.rebuild_idstore_from_dom()?;
430    // Sweep dangling XMRefs that were specifically created by
431    // amsmath::rearrange_ams_split (tagged with `_split_ref="1"`).
432    // The math parser later absorbs some XMArray cells (inserted
433    // MULOPs, etc.) and the parallel XMWrap refs end up pointing
434    // at vanished targets, cascading through Warning:expected:node
435    // (here) and Error:expected:id (post-process) for ~1500 wp3
436    // canvas papers. Restricting the sweep to `_split_ref` avoids
437    // breaking declare_test's renamed-id case (XMRefs pointing to
438    // `S1.Ex1.m1.1`-style ids that resolve through Perl-faithful
439    // idstore staleness; those don't carry the marker).
440    self.prune_dangling_split_xmrefs()?;
441    self.prune_xmduals()?;
442    if let Some(mut root) = self.document.get_root_element() {
443      self.set_local_font(Rc::new(Font::text_default()));
444      self.finalize_rec(&mut root)?;
445      self.set_rdfa_prefixes();
446      self.apply_document_namespace_declarations(&mut root);
447      self.expire_local_font();
448    }
449    Ok(())
450  }
451
452  /// [`finalize`](Self::finalize) restricted to one subtree: resolve fonts
453  /// against ancestors and strip the `_font`/`_autoopened`/… bookkeeping
454  /// attributes, without the document-level passes (idstore rebuild, XMDual
455  /// pruning, RDFa/namespace declarations) that only make sense for a complete
456  /// document.
457  ///
458  /// Used by `latexml_post::make_bibliography` to render a `.bib` field value
459  /// into an XML fragment through the real engine: it absorbs into a scratch
460  /// `ltx:text` wrapper and serializes that wrapper's children.
461  ///
462  /// Whole-document [`finalize`](Self::finalize) cannot serve that caller —
463  /// **not** because it errors (it returns `Ok`), but because running
464  /// `finalize_rec` from the ROOT legitimately UNWRAPS a redundant font-only
465  /// `ltx:text`: measured on such a scratch document, the content survives at
466  /// the root while the caller's wrapper handle is left detached and childless
467  /// (`wrapper_children 1 -> 0`, `parent = None`), so it serializes to nothing.
468  /// Starting the recursion AT the wrapper keeps it addressable.
469  pub fn finalize_subtree(&mut self, node: &mut Node) -> Result<()> {
470    self.set_local_font(Rc::new(Font::text_default()));
471    let result = self.finalize_rec(node);
472    self.expire_local_font();
473    result
474  }
475
476  /// Apply registered document namespace declarations to the root element.
477  /// Perl's RegisterDocumentNamespace stores prefix→URI mappings in the model.
478  /// These must appear as xmlns:prefix="URI" on the root element during serialization.
479  /// Only emit namespaces that are actually used in the document (Perl behavior).
480  fn apply_document_namespace_declarations(&self, root: &mut Node) {
481    let prefixes = model::get_document_namespace_prefixes();
482    // Collect which prefixes are actually used in the document
483    let nsnodes = root.get_namespace_declarations();
484    let existing_prefixes: Vec<String> = nsnodes.iter().map(|ns| ns.get_prefix()).collect();
485    for (prefix, ns_uri) in prefixes {
486      // Skip internal/default namespaces
487      if prefix.is_empty() || prefix == "ltx" || prefix == "xml" {
488        continue;
489      }
490      // Skip namespaces containing "DEFAULT#" (internal model entries)
491      if ns_uri.contains("DEFAULT#") {
492        continue;
493      }
494      // Only add if this prefix appears as a namespace declaration on some child node
495      // (meaning it's actually used in the document)
496      if existing_prefixes.contains(&prefix) {
497        continue; // already declared on root
498      }
499      // Check if any descendant element uses this namespace prefix
500      // by looking for namespace declarations on descendant elements
501      let has_usage = self.has_namespace_usage(root, &prefix);
502      if has_usage {
503        let attr_name = format!("xmlns:{prefix}");
504        root.set_attribute(&attr_name, &ns_uri).ok();
505      }
506    }
507  }
508
509  /// Check if any descendant of node uses the given namespace prefix.
510  fn has_namespace_usage(&self, node: &Node, prefix: &str) -> bool {
511    // Check attributes of this node for prefix: usage. The
512    // allocation-free check `starts_with(prefix) + byte-at-prefix.len()`
513    // replaces `format!("{prefix}:")` which would heap-allocate per node
514    // visited during the recursive descent.
515    let plen = prefix.len();
516    for (key, _) in node.get_attributes() {
517      if key.len() > plen && key.starts_with(prefix) && key.as_bytes()[plen] == b':' {
518        return true;
519      }
520    }
521    // Check children recursively
522    for child in node.get_child_nodes() {
523      if child.get_type() == Some(NodeType::ElementNode) {
524        // Check if element itself is in this namespace
525        for ns in child.get_namespace_declarations() {
526          if ns.get_prefix() == prefix {
527            return true;
528          }
529        }
530        if self.has_namespace_usage(&child, prefix) {
531          return true;
532        }
533      }
534    }
535    false
536  }
537
538  /// Remove xml:ids from XMTok elements that aren't referenced by any idref.
539  /// The Rust math parser generates xml:ids on XMTok nodes for internal XMRef linkage
540  /// during parsing. After finalization (which includes prune_xmduals), some ids
541  /// are no longer referenced. Perl's parser doesn't generate these ids.
542  pub fn cleanup_unreferenced_xmtok_ids(&mut self) {
543    use rustc_hash::FxHashSet as HashSet;
544    let mut referenced_ids: HashSet<String> = HashSet::default();
545    for node in self.findnodes("descendant-or-self::*[@idref]", None) {
546      if let Some(idref) = node.get_attribute("idref") {
547        referenced_ids.insert(idref);
548      }
549    }
550    let xml_ns = "http://www.w3.org/XML/1998/namespace";
551    let toks = self.findnodes("descendant-or-self::ltx:XMTok[@xml:id]", None);
552    for mut tok in toks {
553      if let Some(id) = tok.get_attribute_ns("id", xml_ns)
554        && !referenced_ids.contains(&id)
555      {
556        self.unrecord_id(&id);
557        // Remove both the prefixed attribute and the ns attribute
558        let _ = tok.remove_attribute("xml:id");
559        let _ = tok.remove_attribute_ns("id", xml_ns);
560      }
561    }
562  }
563
564  /// Iterative implementation of finalize_rec to avoid stack overflow.
565  /// Uses an explicit heap-allocated work stack instead of call-stack recursion.
566  /// Resolves fonts for each node relative to its ancestors, and removes
567  /// helper attributes (_font, _standalone_font, etc).
568  fn finalize_rec(&mut self, node: &mut Node) -> Result<()> {
569    // Work items for the iterative traversal.
570    // Enter: process font declarations and children (text children handled inline,
571    //        element children deferred to the stack).
572    // PostElement: after finalizing an element child, check for font wrapper collapse.
573    // PostWork: after all children processed, remove bookkeeping attrs and expire font.
574    #[allow(clippy::enum_variant_names)]
575    enum Work {
576      Enter(Node),
577      PostElement {
578        child:         Node,
579        parent_qname:  SymStr,
580        was_forcefont: bool,
581      },
582      PostWork {
583        node: Node,
584      },
585    }
586
587    let mut stack: Vec<Work> = Vec::new();
588    // Track nodes that have no visible attributes after bookkeeping removal.
589    // Used in PostElement to decide collapse without calling get_attributes()
590    // (which can crash on corrupted libxml2 attribute lists after replace_node).
591    let mut empty_attr_nodes: HashSet<usize> = HashSet::default();
592    // Defer collapse operations to avoid libxml2 memory corruption.
593    // Collapsing nodes during tree traversal corrupts attribute linked lists
594    // of ancestor nodes. By collecting collapses and running them after
595    // the entire traversal completes, we avoid accessing corrupted state.
596    let mut deferred_collapses: Vec<(Node, SymStr)> = Vec::new();
597    stack.push(Work::Enter(node.clone()));
598
599    while let Some(work) = stack.pop() {
600      match work {
601        Work::Enter(mut current) => {
602          let raw_qname = get_node_qname(&current);
603          // Streaming pass 2: judge the fragment's parse wrapper as the REAL
604          // parent it splices back under, so schema decisions for top-level
605          // fragment content match the eager walk (see the field docs).
606          let qname = match self.fragment_parent_qname {
607            Some(parent) if raw_qname == pin!("ltx:_lxfragment") => parent,
608            _ => raw_qname,
609          };
610          let local_font = self.get_local_font().unwrap();
611          // _standalone_font is typically for metadata that gets extracted out of context
612          let mut declared_font = if current.has_attribute("_standalone_font") {
613            Cow::Borrowed(&*FONT_TEXT_DEFAULT)
614          } else {
615            Cow::Borrowed(&*local_font)
616          };
617
618          // TODO: _pre_comment / _comment insertion requires create_comment support in libxml
619          // wrapper Perl: parent.insertBefore(XML::LibXML::Comment.new(comment), node)
620          // Perl: parent.insertAfter(XML::LibXML::Comment.new(comment), node)
621
622          // Use boxed HashMap to reduce work item size — Font is ~500 bytes per entry
623          let mut pending_declaration: Box<HashMap<String, (String, Font)>> = Box::default();
624
625          if self.has_node_font(&current) {
626            let desired_font = self.get_node_font(&current);
627            *pending_declaration = desired_font.relative_to(&declared_font);
628            if (current.get_first_child().is_some() || current.has_attribute("_force_font"))
629              && !pending_declaration.is_empty()
630            {
631              let mut keys_to_remove: Vec<SymStr> = Vec::new();
632              let mut attrs_to_set: Vec<(SymStr, SymStr)> = Vec::new();
633              for (key, (value, properties)) in pending_declaration.iter() {
634                if model::can_have_attribute(qname, arena::pin(key)) {
635                  let key_sym = arena::pin(key);
636                  attrs_to_set.push((key_sym, arena::pin(value)));
637                  // Merge to set the font currently in effect
638                  declared_font = Cow::Owned(declared_font.merge_ref(properties));
639                  keys_to_remove.push(key_sym);
640                }
641              }
642
643              for (key, mut value) in attrs_to_set {
644                if key == pin!("class") {
645                  // Merge and sort class values alphabetically, matching Perl's behavior
646                  if let Some(ovalue) = current.get_attribute("class") {
647                    let new_s = arena::with(value, |s| s.to_string());
648                    let mut classes: Vec<&str> = new_s
649                      .split_whitespace()
650                      .chain(ovalue.split_whitespace())
651                      .collect();
652                    classes.sort_unstable();
653                    classes.dedup();
654                    value = arena::pin(classes.join(" "));
655                  }
656                }
657                // Resolve to owned Strings before calling set_attribute,
658                // to avoid holding an arena borrow while set_attribute may need arena::pin
659                // for schema-based attribute filtering (canHaveAttribute check).
660                let key_s = arena::with(key, |s| s.to_string());
661                let value_s = arena::with(value, |s| s.to_string());
662                self.set_attribute(&mut current, &key_s, &value_s)?;
663              }
664              for key in keys_to_remove {
665                arena::with(key, |key_str| pending_declaration.remove(key_str));
666              }
667            }
668          }
669          // Optionally add ids to all nodes (AFTER all parsing, rearrangement, etc)
670          // (gate on the RAW name: a fragment wrapper standing in for its
671          // real parent must not mint an id — it is never serialized).
672          if raw_qname != pin!("ltx:document")
673            && raw_qname != pin!("ltx:_lxfragment")
674            && state::lookup_bool("GENERATE_IDS")
675            && !current.has_attribute("xml:id")
676            && !current.has_attribute("id") // SVG elements with plain id don't need xml:id
677            && arena::with(qname, |qname_str| can_have_attribute(qname_str, "xml:id"))
678          {
679            self.generate_id(&mut current, "")?;
680          }
681          self.set_local_font(Rc::new(declared_font.into_owned()));
682
683          // Process children using the snapshot from get_child_nodes().
684          // Element children are deferred to the stack; text children are handled inline.
685          // PostWork is pushed first (runs after all children), then children in reverse.
686          //
687          // No attribute snapshot is taken here: PostWork deliberately
688          // re-derives the bookkeeping set at its own time (see the comment
689          // there — descendant `generate_id` calls write `_ID_counter_*` onto
690          // this node AFTER Enter). Capturing it here cost a full
691          // `get_attributes()` HashMap plus a String per attribute on every
692          // node — ~10M nodes on the streaming witness — and the result was
693          // never read.
694          stack.push(Work::PostWork { node: current.clone() });
695
696          let children = current.get_child_nodes();
697          // Collect work items forward, then push in reverse for left-to-right processing
698          let mut child_work: Vec<Work> = Vec::new();
699          for child in &children {
700            let child_type = child.get_type();
701            if child_type == Some(NodeType::ElementNode) {
702              let was_forcefont = child.has_attribute("_force_font");
703              // Enter first, PostElement after (reversed when pushed to stack)
704              child_work.push(Work::Enter(child.clone()));
705              child_work.push(Work::PostElement {
706                child: child.clone(),
707                parent_qname: qname,
708                was_forcefont,
709              });
710            } else if child_type == Some(NodeType::TextNode) {
711              // Text node: wrap with font element if needed (handled inline)
712              let mut text_keys_to_remove = Vec::new();
713              for key in pending_declaration.keys() {
714                if !can_have_attribute(FONT_ELEMENT_NAME, key) {
715                  text_keys_to_remove.push(key.clone());
716                }
717              }
718              for key in text_keys_to_remove {
719                pending_declaration.remove(&key);
720              }
721              if can_contain(&current, FONT_ELEMENT_NAME)
722                && !pending_declaration.is_empty()
723                && let Some(mut text) = self.wrap_nodes(FONT_ELEMENT_NAME, vec![child.clone()])?
724              {
725                for (key, (value, _properties)) in pending_declaration.iter() {
726                  self.set_attribute(&mut text, key, value)?;
727                }
728                // Text wrapper finalization is shallow (only text content), push to stack
729                child_work.push(Work::Enter(text));
730              }
731            }
732          }
733          // Push in reverse so left-to-right processing order is maintained
734          for work_item in child_work.into_iter().rev() {
735            stack.push(work_item);
736          }
737        },
738        Work::PostElement {
739          child,
740          parent_qname,
741          was_forcefont,
742        } => {
743          // After finalizing a child element, check if it should be collapsed.
744          // Use empty_attr_nodes set instead of child.get_attributes().is_empty()
745          // to avoid traversing the attribute linked list, which can be corrupted
746          // by prior replace_node operations in libxml2.
747          // Defer the actual collapse to after the traversal completes, since
748          // replace_node can corrupt ancestor attribute lists in libxml2.
749          if (get_node_qname(&child) == pin!("ltx:text"))
750            && !was_forcefont
751            && empty_attr_nodes.contains(&child.to_hashable())
752          {
753            let grandchildren = child.get_child_nodes();
754            if grandchildren
755              .iter()
756              .all(|gchild| can_contain_qsym(parent_qname, get_node_qname(gchild)))
757            {
758              deferred_collapses.push((child, parent_qname));
759            }
760          }
761        },
762        Work::PostWork { mut node } => {
763          // Mirrors Perl `Document.pm:452`: at finalize time, ANY attribute
764          // whose name starts with `_` is internal bookkeeping and gets
765          // stripped. The set MUST be derived here, at PostWork time, and not
766          // captured back at Enter, because descendant
767          // `generate_id` calls can write `_ID_counter_<prefix>_` attributes
768          // ONTO the current node (their nearest-id-bearing ancestor) during
769          // child traversal — those late-added attrs were missing from the
770          // Enter snapshot and would otherwise leak into the output XML,
771          // causing duplicate xml:id collisions in the post-processing
772          // libxml2 validator (1312.5864 cluster: 70× `S8.T5.m2241 already
773          // defined`, where the Math element carried `_ID_counter__="1"`
774          // populated post-Enter).
775          //
776          // EXCEPT the streaming pass-2 parse wrapper: it is never
777          // serialized, and its `_ID_counter_*` attrs are how id numbering
778          // carries ACROSS segments (read back after each segment — see
779          // `streaming_pass2`).
780          if get_node_qname(&node) != pin!("ltx:_lxfragment") {
781            let attrs_now = node.get_attributes();
782            let total_attrs = attrs_now.len();
783            let bookkeeping_attrs: Vec<&String> = attrs_now
784              .keys()
785              .filter(|name| name.starts_with('_'))
786              .collect();
787            let bookkeeping_count = bookkeeping_attrs.len();
788            for name in bookkeeping_attrs {
789              let _ = node.remove_attribute(name);
790            }
791            // If all attributes were bookkeeping, the node now has empty attrs.
792            if total_attrs <= bookkeeping_count {
793              empty_attr_nodes.insert(node.to_hashable());
794            }
795          }
796          self.expire_local_font();
797        },
798      }
799    }
800    // Execute deferred font wrapper collapses now that the entire tree has been
801    // finalized. Process from deepest (last) to shallowest (first) to avoid
802    // corrupting ancestor attribute lists during the replacement operations.
803    // This deferred approach prevents libxml2 memory corruption that occurs
804    // when replace_node runs during tree traversal.
805    for (child, _parent_qname) in deferred_collapses.into_iter().rev() {
806      let grandchildren = child.get_child_nodes();
807      if grandchildren.is_empty() {
808        // Empty font wrapper — just remove it
809        self.remove_node(child);
810      } else {
811        Debug!(
812          "will replace {} grandchildren nodes in finalize_rec (deferred)",
813          grandchildren.len()
814        );
815        self.replace_node(child, grandchildren)?;
816      }
817    }
818    Ok(())
819  }
820
821  /// Document construction at the Current Insertion Point.
822  ///
823  /// absorb the given $box into the DOM (called from constructors).
824  /// This will return a list of whatever nodes were created.
825  /// Note that this may include nodes that are children of other nodes in the list
826  /// or nodes that are no longer in the document.
827  /// Also, note that when a text nodes is appended to, the complete text node is in the list,
828  /// not just the portion that was added.
829  /// [Note that recording the nodes being constructed isn't all that costly,
830  /// but filtering them for parent/child relations IS, particularly since it usually isn't needed]
831  ///
832  /// A box that is a TBox, or List, or Whatsit, is responsible for carrying out
833  /// its own insertion, but it should ultimately call methods of Document
834  /// that will record the nodes that were created.
835  /// $box can also be a plain string (Digested::Postponed)
836  /// which will be inserted according to whatever
837  /// font, mode, etc, are in %props.
838  pub fn absorb(&mut self, object: &Digested, props_opt: Option<SymHashMap<Stored>>) -> Result<()> {
839    use DigestedData::*;
840    let props = props_opt.unwrap_or_default();
841    let mut boxes = vec![Cow::Borrowed(object)];
842    while let Some(front_box) = boxes.pop() {
843      // Cooperative guard tick — the SAME one the digestion loops run
844      // (`stomach.rs`, three sites). Build had none, and Build is where a large
845      // document actually peaks: measured 2026-07-29 on 800k words of plain
846      // prose, digest ends under 2 GB while Build takes it to 6.4 GB (54 % of
847      // wall, ~70 % of peak RSS). So `--max-memory` guarded only the cheap
848      // phase, and what a user hit was the HARD watchdog: SIGKILL, exit 137, no
849      // `Fatal:` line, no partial document — the 0-byte output reported against
850      // rc4 on a 131 MB source. The tick makes the ceiling cooperative here
851      // too, so an over-budget document degrades to a graceful Fatal that the
852      // caller can salvage from (`core_interface::convert_document`).
853      //
854      // Cost is negligible: `check_timeout` only samples RSS every ~1024 calls,
855      // and this loop already does far more work per iteration than a counter
856      // increment.
857      crate::stomach::check_timeout()?;
858      match front_box.data() {
859        List(list) => {
860          // Simply unwind Lists to avoid unneccessary recursion; This occurs quite frequently!
861          for tbox in list.borrow().unlist().into_iter().rev() {
862            boxes.push(Cow::Owned(tbox));
863          }
864        },
865        // A Proper Box or Whatsit? Absorb it.
866        TBox(digested) => {
867          self.set_box_to_absorb(Some((*front_box).clone()));
868          self.init_constructed_nodes();
869          digested.borrow().be_absorbed(self)?;
870          // record these for OUTER caller, but return only the most recent set
871          self.close_constructed_nodes();
872          self.expire_box_to_absorb();
873        },
874        Whatsit(digested) => {
875          self.set_box_to_absorb(Some((*front_box).clone()));
876          self.init_constructed_nodes();
877          digested.borrow().be_absorbed(self)?;
878          self.close_constructed_nodes();
879          self.expire_box_to_absorb();
880        },
881        Alignment(alignment) => {
882          self.set_box_to_absorb(Some((*front_box).clone()));
883          self.init_constructed_nodes();
884          // A self-referential alignment (transitively contains itself) would
885          // re-enter `borrow_mut` here and panic ("already borrowed"). Guard like
886          // the size-computation traversal (digested.rs) and skip the re-entrant
887          // absorption to break the cycle. Witness: astro-ph0310145 et al.
888          match alignment.try_borrow_mut() {
889            Ok(mut a) => {
890              a.be_absorbed_mut(self)?;
891            },
892            Err(_) => {
893              // Degrading MUST be loud (fail-toward-flagging): the whole
894              // alignment's content is being dropped from the output.
895              Error!(
896                "unexpected",
897                "self_referential_alignment",
898                "Skipping absorption of a self-referential alignment (its content is lost)"
899              );
900            },
901          }
902          self.close_constructed_nodes();
903          self.expire_box_to_absorb();
904        },
905        Comment(comment) => {
906          comment.be_absorbed(self)?;
907        },
908        Postponed(tokens) => {
909          let text_font_opt = if let Some(Stored::Font(prop_font)) = props.get("font") {
910            Some(Rc::clone(prop_font))
911          } else {
912            match self.box_to_absorb {
913              Some(ref thisbox) => thisbox.get_font()?,
914              None => None,
915            }
916          };
917          let text_font = text_font_opt.unwrap_or_default();
918          if let Some(new_text) = self.open_text(&tokens.to_string(), &text_font)? {
919            self.record_constructed_node(&new_text);
920          }
921        },
922        KeyVals(kv) => {
923          // When KeyVals appear in body absorption (e.g. #1 for RequiredKeyVals),
924          // convert them to text representation matching Perl's stringify behavior.
925          let text = kv.to_string();
926          if !text.is_empty() {
927            let text_font = match self.box_to_absorb {
928              Some(ref thisbox) => thisbox.get_font()?.unwrap_or_default(),
929              None => Rc::default(),
930            };
931            if let Some(new_text) = self.open_text(&text, &text_font)? {
932              self.record_constructed_node(&new_text);
933            }
934          }
935        },
936        RegisterValue(_) => {
937          // RegisterValue should not normally appear in the absorption pipeline.
938        },
939      }
940    }
941    Ok(())
942  }
943
944  fn init_constructed_nodes(&mut self) {
945    // Pop a buffer from the free-list (retaining its previously-grown capacity);
946    // fall back to a fresh empty Vec if the pool is dry. Swap it in as the new
947    // inner frame; the outgoing outer frame goes onto the save stack.
948    let fresh = self.reusable_node_buffers.pop().unwrap_or_default();
949    let prev = std::mem::replace(&mut self.constructed_nodes, fresh);
950    self.localized_constructed_nodes.push(prev);
951  }
952
953  /// Close the current constructed-nodes frame, restoring the outer frame and
954  /// re-recording the inner frame's nodes into it. The drained inner buffer
955  /// is returned to `reusable_node_buffers` (empty but with capacity intact)
956  /// so the next `init_constructed_nodes` can reuse it without allocating.
957  fn close_constructed_nodes(&mut self) {
958    let outer = self.localized_constructed_nodes.pop().unwrap_or_default();
959    let mut inner = std::mem::replace(&mut self.constructed_nodes, outer);
960    for n in inner.drain(..) {
961      self.record_constructed_node(&n);
962    }
963    // `inner` is now empty; its capacity is preserved. Return it to the pool.
964    self.reusable_node_buffers.push(inner);
965  }
966  pub fn get_constructed_nodes(&self) -> &[Node] { &self.constructed_nodes }
967
968  /// This is a refactored `else` cases from the main absorb routine, to allow for better type
969  /// hygiene
970  pub fn absorb_string(
971    &mut self,
972    object: &str,
973    props: &SymHashMap<Stored>,
974  ) -> Result<Option<Node>> {
975    // Else, plain string in text mode.
976    let ismath: bool = match props.get("isMath") {
977      Some(v) => v.into(),
978      None => false,
979    };
980    if !ismath {
981      // Perf: avoid cloning Rc<Font> into owned Font in the common case.
982      // We pull out the Rc (shared reference is fine since open_text only
983      // borrows the Font, not self) and go through Cow<Font>.
984      let font_opt: Option<Rc<Font>> = match props.get("font") {
985        Some(Stored::Font(fnt)) => Some(Rc::clone(fnt)),
986        Some(Stored::FontDirective(FontDirective::Asset(fnt))) => Some(Rc::clone(fnt)),
987        _ => None,
988      };
989      if let Some(fnt) = font_opt {
990        return self.open_text(object, &fnt);
991      }
992      if let Some(Stored::FontDirective(FontDirective::Closure(code))) = props.get("font") {
993        let fnt = code(None)?;
994        return self.open_text(object, &fnt);
995      }
996      // Fallback to box_to_absorb font.
997      let fnt = self.box_to_absorb.as_ref().unwrap().get_font()?.unwrap();
998      self.open_text(object, &fnt)
999    } else if get_node_qname(&self.node) == pin!("ltx:XMTok") {
1000      // Or plain string in math mode.
1001      // Note text nodes can ONLY appear in <XMTok> or <text>!!!
1002      // Have we already opened an XMTok? Then insert into it.
1003      Ok(Some(self.open_math_text_internal(object)?))
1004    // Else create the XMTok now.
1005    } else {
1006      // Odd case: constructors that work in math & text can insert raw strings in Math mode.
1007      let font_math_opt = match props.get("font") {
1008        Some(Stored::Font(fnt)) => Some(Cow::Borrowed(&**fnt)),
1009        Some(Stored::FontDirective(FontDirective::Asset(fnt))) => Some(Cow::Borrowed(&**fnt)),
1010        Some(Stored::FontDirective(FontDirective::Closure(code))) => Some(Cow::Owned(code(None)?)),
1011        _ => None,
1012      };
1013      if let Some(font_math) = font_math_opt {
1014        Ok(Some(self.insert_math_token(
1015          object,
1016          HashMap::default(),
1017          Some(&font_math),
1018        )?))
1019      } else {
1020        Ok(Some(self.insert_math_token(
1021          object,
1022          HashMap::default(),
1023          None,
1024        )?))
1025      }
1026    }
1027  }
1028  //%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
1029
1030  /// Perl: insertElementBefore — insert a new element before a given point node.
1031  /// Creates a new element with the given qname and attributes, inserts it
1032  /// before `point` in the DOM tree, and returns the new node.
1033  pub fn insert_element_before(
1034    &self,
1035    point: &Node,
1036    qname: &str,
1037    attrib: Option<HashMap<String, String>>,
1038  ) -> Result<Node> {
1039    // Create element in LTX namespace (matching Perl's setNamespace($LTX_NS,'',1))
1040    let mut new_node = Node::new(qname, None, &self.document)?;
1041    if let Some(attrs) = attrib {
1042      for (key, value) in attrs {
1043        let _ = new_node.set_attribute(&key, &value);
1044      }
1045    }
1046    // insertBefore: add new_node before point
1047    let mut point_mut = point.clone();
1048    point_mut.add_prev_sibling(&mut new_node).ok();
1049    Ok(new_node)
1050  }
1051
1052  /// Shorthand for open,absorb,close, but returns the new node.
1053  pub fn insert_element(
1054    &mut self,
1055    qname: &str,
1056    content: Vec<&Digested>,
1057    attrib: Option<HashMap<String, String>>,
1058  ) -> Result<Node> {
1059    // TODO: Quickly hacked together, needs a careful refactor with all .clone()
1060    // calls removed
1061    let node = self.open_element(qname, attrib, None)?;
1062    // Debug!("Inserting element {:?} with body: {:?}", qname, content);
1063    for digested in content {
1064      self.absorb(digested, None)?;
1065    }
1066
1067    // Walk up from the current insertion point to learn whether `node` is still
1068    // an open ancestor (so it can be closed below). `self.node` may be the
1069    // parentless document root, and an intermediate node may be detached — a
1070    // `None` parent there simply means `node` is NOT an ancestor (stop the walk),
1071    // so don't `.unwrap()`-panic on it. Witnesses: hep-th0201062, 1009.0637.
1072    let mut c = self.node.get_parent();
1073    while c.is_some()
1074      && c.as_ref() != Some(&node)
1075      && c.as_ref().unwrap().get_type() != Some(NodeType::DocumentNode)
1076    {
1077      c = match c.as_ref().unwrap().get_parent() {
1078        None => None,
1079        Some(parent) => match parent.get_type() {
1080          Some(NodeType::DocumentNode) | None => None,
1081          Some(_) => Some(parent),
1082        },
1083      };
1084    }
1085
1086    // In obscure situations, `node` may have already gotten closed?
1087    // close it if it is still open.
1088    if (self.node == node) || (c.as_ref() == Some(&node)) {
1089      self.close_element(qname)?;
1090    }
1091    Ok(node)
1092  }
1093
1094  /// Insert a ProcessingInstruction of the form <?op attr=value ...?>
1095  /// Does NOT move the current insertion point to the PI,
1096  /// but may move up past a text node.
1097  // Rust note: attrib would have been best as Vec<(String,String)> but
1098  // currently quote!() doesn't work out of the box on them
1099  pub fn insert_pi(
1100    &mut self,
1101    op: &str,
1102    attributes_opt: Option<HashMap<String, String>>,
1103  ) -> Result<()> {
1104    let mut attr_data = Vec::new();
1105    if let Some(attributes) = attributes_opt {
1106      let mut keys = vec!["class", "package", "options"];
1107      let other_keys = attributes
1108        .keys()
1109        .filter(|k| k.as_str() != "class" && k.as_str() != "package" && k.as_str() != "options")
1110        .map(String::as_str)
1111        .collect::<Vec<_>>();
1112      keys.extend(other_keys);
1113      for key in keys {
1114        if let Some(value) = attributes.get(key) {
1115          attr_data.push(s!("{}=\"{}\"", key, value));
1116        }
1117      }
1118    }
1119    // self.close_text_internal();  // Close any open text node
1120    let mut pi_node = self
1121      .document
1122      .create_processing_instruction(op, &attr_data.join(" "))
1123      .unwrap();
1124    // Perl (Core/Document.pm insertPI): a PI always lands before the root
1125    // element. When the root already exists — a mid-body or document-final PI
1126    // (#683's `<?latexml nominal-font-size?>` is emitted at finalization) —
1127    // insert directly before it, whatever the current insertion point. Only
1128    // when NO root has been opened yet (the usual pre-`\begin{document}` case)
1129    // queue the PI in `pending`, to be flushed the moment the root opens
1130    // (`open_element_at`). The old code keyed solely on the insertion point
1131    // being the DocumentNode and so re-queued a post-root PI into `pending`,
1132    // which drains once and never again — the PI was silently lost.
1133    if let Some(mut root) = self.document.get_root_element() {
1134      root.add_prev_sibling(&mut pi_node)?;
1135    } else if self.node.get_type() == Some(NodeType::DocumentNode) {
1136      self.pending.push(pi_node);
1137    } else {
1138      self.node.add_prev_sibling(&mut pi_node)?;
1139    }
1140    Ok(())
1141  }
1142
1143  /// Open a new `qname` element and make it the current insertion point.
1144  ///
1145  /// Where it opens is the model's decision, not the caller's: the insertion
1146  /// point is first moved by [`find_insertion_point`](Self::find_insertion_point),
1147  /// which auto-opens any element the schema requires in between and
1148  /// auto-closes any that cannot contain `qname`. So a binding may open a
1149  /// `ltx:para` without first checking what is currently open.
1150  ///
1151  /// See [`open_element_at`](Self::open_element_at) to open at an explicit node
1152  /// instead, and [`close_element`](Self::close_element) for the counterpart.
1153  pub fn open_element(
1154    &mut self,
1155    qname: &str,
1156    attributes: Option<HashMap<String, String>>,
1157    font_opt: Option<&Font>,
1158  ) -> Result<Node> {
1159    // NoteProgress('.') if (self.progress}++ % 25) == 0;
1160    // Debug!(
1161    //   s!("Open element {:?} at {:?}",
1162    //   qname,
1163    //   self.with_node_qname(&self.node))
1164    // );
1165    let mut point = self.find_insertion_point(qname, None)?;
1166    let newnode = self.open_element_at(&mut point, qname, attributes, font_opt.cloned())?;
1167    self.set_node(&newnode);
1168    // Underscore attributes such as _box and _font from LaTeXML-proper are now
1169    // bookkept in special substructs of Document Connected to the node hash.
1170    // Ideally should be as quick to recompute natively as it would be to set/get
1171    // attributes externally via libxml.
1172    //
1173    // TODO: also accept a _box argument eventually? Or store differently?
1174    // attributes.entry("_box").or_insert(state_mut!().locals.box);
1175
1176    Ok(newnode)
1177  }
1178
1179  /// Stamp a freshly-opened element with its source range as a
1180  /// `data-sourcepos` attribute, for the `--source-map` feature (issues
1181  /// #47/#92). The range comes from the construct currently being absorbed
1182  /// (`box_to_absorb`); the integer file `tag` is resolved through the
1183  /// document-level `sources` table (`state::source_tag`) so no path is
1184  /// inlined. See `docs/performance/SOURCE_PROVENANCE.md` §0/§2.
1185  ///
1186  /// Math is kept **opaque** per the MVP scope: the `ltx:Math` wrapper is
1187  /// stamped, but its `ltx:XM*` MathML internals are skipped (the Marpa
1188  /// math parser has no locator awareness — §7 A.3). Only invoked when the
1189  /// source-map switch is on (the caller gates it).
1190  fn stamp_source_locator(&mut self, node: &Node, qname: &str) {
1191    // Math internals: stamp only the leaf token elements (`ltx:XMTok` — the
1192    // operators / identifiers / numbers) when token-locators gives them a real
1193    // located box locator (the math char's source origin). This is the per-token
1194    // in-equation provenance step (§7 A.3). The structural XM* (XMApp/XMDual/
1195    // XMArray) are rebuilt by the Marpa parser — created directly, not via
1196    // `open_element` — so they never reach here; the remaining digestion-built
1197    // wrappers (XMArg/XMHint/XMText/XMRef/XMWrap) stay opaque. The `data:sourcepos`
1198    // rides the XMTok element through the parser's restructuring (attribute on a
1199    // reparented node) and through the XMath→MathML XSLT.
1200    //
1201    // Gated at compile time: feature-OFF keeps math fully opaque (the MVP scope
1202    // and the golden's math-opacity assertion); only the token-locators build
1203    // exposes the located XMTok leaves.
1204    #[cfg(not(feature = "token-locators"))]
1205    if qname.starts_with("ltx:XM") {
1206      return;
1207    }
1208    #[cfg(feature = "token-locators")]
1209    if qname.starts_with("ltx:XM") && qname != "ltx:XMTok" {
1210      return;
1211    }
1212    // Read the pre-captured Copy locator — never re-borrow the box here.
1213    let Some(loc) = self.current_box_locator else {
1214      return;
1215    };
1216    // Skip locators with no real source position (default/synthetic).
1217    if loc.from_line == 0 {
1218      return;
1219    }
1220    // User-source only (§7.B): emit a navigable locator only into an editable
1221    // user document — `.tex`/`.ltx`, plus the bibliography sources `.bbl` (the
1222    // BibTeX-generated, but author-editable, list of `\bibitem`s) and `.bib`
1223    // (BibTeX database entries). All four are files the editor may legitimately
1224    // scroll into. This skips both synthetic default locators (whose source is
1225    // `…/locator.rs`, from `Locator::default()`'s `file!()`) and foreign
1226    // package/class files (`.sty`/`.cls`/…) — the editor must never scroll into
1227    // those. Foreign/unstamped elements inherit their nearest user-source
1228    // ancestor's range client-side (DOM walk-up). (MVP heuristic; a tracked
1229    // user-input set would be more precise.)
1230    let src = loc.get_source();
1231    let is_user_source = arena::with(src, |s| {
1232      let s = s.to_ascii_lowercase();
1233      s.ends_with(".tex") || s.ends_with(".ltx") || s.ends_with(".bbl") || s.ends_with(".bib")
1234    });
1235    if !is_user_source {
1236      return;
1237    }
1238    let tag = state::source_tag(src);
1239    // Emit in LaTeXML's `data:` namespace (`http://dlmf.nist.gov/LaTeXML/data`,
1240    // registered in `base_schema.rs:19`). The post XSLT's `copy_foreign_attributes`
1241    // path converts a `data:`-prefixed *foreign-namespaced* attribute to the HTML
1242    // `data-sourcepos` attribute (`LaTeXML-common.xsl`: `data:` prefix → `data-…`
1243    // when `USE_DATA_ATTRIBUTES` = true, i.e. HTML5). Faithful to Perl LaTeXML's
1244    // foreign-attribute convention; no XSLT change needed. The general namespaced-
1245    // attribute binding lives in `set_attribute` (shared with `aria:` etc.).
1246    let mut n = node.clone();
1247    let _ = self.set_attribute(&mut n, "data:sourcepos", &loc.to_sourcepos(tag));
1248  }
1249
1250  /// Note: This closes the deepest open node of a given type.
1251  /// This can cause problems with auto-opened nodes, esp. ones for fontswitches!
1252  /// Since this is an "explicit request", we're currently skipping over those nodes,
1253  /// ie. we're automatically closing them, even if they're the same type as we're asking to
1254  /// close!!! This is kinda risky! Maybe we should try to request closing of specific nodes.
1255  pub fn close_element(&mut self, qname: &str) -> Result<Option<Node>> {
1256    Debug!(
1257      "document",
1258      "close_element",
1259      s!(
1260        "Close element {:?} at {:?}",
1261        qname,
1262        self.document.node_to_string(&self.node)
1263      )
1264    );
1265    let qsym = arena::pin(qname);
1266    self.close_text_internal()?;
1267    let mut node = self.node.clone();
1268    let mut cant_close = Vec::new();
1269    while node.get_type() != Some(NodeType::DocumentNode) {
1270      let t = get_node_qname(&node);
1271      // autoclose until node of same name BUT also close nodes opened' for font
1272      // switches!
1273      if t == qsym && !(t == pin!("ltx:text") && node.has_attribute("_fontswitch")) {
1274        break;
1275      }
1276      if !can_auto_close(&node) {
1277        cant_close.push(node.clone());
1278      }
1279      match node.get_parent() {
1280        Some(parent) => {
1281          node = parent;
1282        },
1283        None => break, // detached node — treat as not found
1284      }
1285    }
1286
1287    if node.get_type() == Some(NodeType::DocumentNode) {
1288      // Didn't find $qname at all!!
1289      let qname_msg: String = match qname {
1290        "#PCDATA" => qname.to_owned(),
1291        _ => s!("</{qname}>"),
1292      };
1293      let message = s!(
1294        "Attempt to close {}, which isn't open. Currently in {}",
1295        qname_msg,
1296        self.get_insertion_context(None)?
1297      );
1298      Error!("malformed", qname, message);
1299      Ok(None)
1300    } else {
1301      // Found node.
1302      if !cant_close.is_empty() {
1303        // Intervening non-auto-closeable nodes!!
1304        let message = s!(
1305          "Closing tag {:?} whose open descendents do not auto-close. Descendants are {:?}",
1306          qname,
1307          cant_close
1308            .into_iter()
1309            .map(|n| n.get_name())
1310            .collect::<Vec<String>>()
1311            .join(",")
1312        );
1313        Error!("malformed", qname, message);
1314      }
1315      // So, now close up to the desired node.
1316      self.close_node_internal(&node)?;
1317      Ok(Some(node))
1318    }
1319  }
1320
1321  // Check whether it is possible to open $qname at this point,
1322  // possibly by autoOpen'ing & autoClosing other tags.
1323  pub fn is_openable(&self, test_qname: &str) -> bool {
1324    let mut node_opt = Some(self.node.clone());
1325    let test_sym = arena::pin(test_qname);
1326    while let Some(node) = node_opt {
1327      let node_qname = get_node_qname(&node);
1328      if sym_can_contain_somehow(node_qname, test_sym).is_some() {
1329        return true;
1330      } else if !can_auto_close(&node) {
1331        return false; // could close, then check if parent can contain
1332      } else {
1333        node_opt = node.get_parent();
1334      }
1335    }
1336    false
1337  }
1338
1339  /// Check whether it is possible to close each element in @tags,
1340  /// any intervening nodes must be autocloseable.
1341  /// returning the last Some(node) that would be closed if it is possible,
1342  /// otherwise None
1343  pub fn is_closeable<T: IntoVDQS>(&self, tags: T) -> Option<Node> {
1344    let mut tags: VecDeque<SymStr> = tags.into_vdqs();
1345    let mut node_opt = if self.node.get_type() == Some(NodeType::TextNode) {
1346      self.node.get_parent()
1347    } else {
1348      Some(self.node.clone())
1349    };
1350    while let Some(qname) = tags.pop_front() {
1351      'inner: loop {
1352        let node: &Node = match node_opt {
1353          None => break,
1354          Some(ref n) => n,
1355        };
1356        let node_type = node.get_type();
1357        if node_type == Some(NodeType::DocumentNode) || node_type.is_none() {
1358          return None;
1359        }
1360        let this_qname = get_node_qname(node);
1361        if this_qname == qname {
1362          break 'inner;
1363        }
1364        if !can_auto_close(node) {
1365          Debug!(
1366            "It was impossible to autoclose node: {:?}",
1367            self.document.node_to_string(node)
1368          );
1369          return None;
1370        }
1371        node_opt = node.get_parent();
1372      }
1373      if !tags.is_empty()
1374        && let Some(node) = node_opt
1375      {
1376        node_opt = node.get_parent();
1377      }
1378    }
1379    node_opt
1380  }
1381
1382  /// Close `qname`, if it is closeable — the forgiving counterpart to
1383  /// [`close_element`](Self::close_element).
1384  ///
1385  /// Returns the closed node, or `None` when no such element is open (or it
1386  /// cannot be auto-closed from here). That "or nothing happens" is the point:
1387  /// it lets a binding close an element it *may* have opened without having to
1388  /// track whether it did, where `close_element` would report an error.
1389  pub fn maybe_close_element(&mut self, qname: &str) -> Result<Option<Node>> {
1390    match self.is_closeable(qname) {
1391      Some(node) => {
1392        self.close_node_internal(&node)?;
1393        Ok(Some(node))
1394      },
1395      _ => Ok(None),
1396    }
1397  }
1398
1399  /// Closes all nodes until $node becomes the current point.
1400  pub fn close_to_node(&mut self, node: &Node, ifopen: bool) -> Result<()> {
1401    let mut cant_close = Vec::new();
1402    let mut lastopen: Option<Node> = None;
1403    let mut n = self.node.clone();
1404    let mut n_type = n.get_type();
1405    // go up the tree from current node, till we find `node`
1406    while n_type != Some(NodeType::DocumentNode) && &n != node {
1407      if !can_auto_close(&n) {
1408        cant_close.push(n.clone());
1409      }
1410      lastopen = Some(n.clone());
1411      match n.get_parent() {
1412        Some(p) => {
1413          n = p;
1414          n_type = n.get_type();
1415        },
1416        _ => {
1417          break;
1418        },
1419      }
1420    }
1421    if n_type == Some(NodeType::DocumentNode) {
1422      // Didn't find $node at all!!
1423      // Perl: suppress error when $ifopen is true
1424      if !ifopen {
1425        let message = s!("Attempt to close {:?}, which isn't open", node.get_name());
1426        arena::with(get_node_qname(node), |qname_str| {
1427          {
1428            Error!("malformed", qname_str, message)
1429          };
1430          Ok(())
1431        })?;
1432      }
1433    } else {
1434      // Found node.
1435      if !cant_close.is_empty() {
1436        // But found has intervening non-auto-closeable nodes!!
1437        let qname = get_node_qname(node);
1438        let message = s!(
1439          "Closing {:?} whose open descendents do not auto-close. Descendants are: {:?}",
1440          qname,
1441          cant_close
1442            .into_iter()
1443            .map(|n| n.get_name())
1444            .collect::<Vec<String>>()
1445            .join(",")
1446        );
1447        arena::with(qname, |qname_str| {
1448          {
1449            Error!("malformed", qname_str, message)
1450          };
1451          Ok(())
1452        })?;
1453      }
1454      if let Some(lastopen_node) = lastopen {
1455        self.close_node_internal(&lastopen_node)?;
1456      }
1457    }
1458    Ok(())
1459  }
1460
1461  /// Closes all nodes until $node is closed.
1462  pub fn close_node(&mut self, node: &Node) -> Result<()> {
1463    self.close_node_with_strictness(true, node)
1464  }
1465  /// Only if needed/possible: closes all nodes until $node is closed
1466  pub fn maybe_close_node(&mut self, node: &Node) -> Result<()> {
1467    self.close_node_with_strictness(false, node)
1468  }
1469
1470  pub fn close_node_with_strictness(&mut self, strict: bool, node: &Node) -> Result<()> {
1471    // Perl: my ($t, @cant_close) = (); ... while ((($t = $n->getType) != XML_DOCUMENT_NODE) ...
1472    let mut cant_close: Vec<Node> = Vec::new();
1473    let mut n = self.node.clone();
1474    let mut t = n.get_type(); // track walker node type, not target
1475    while t.is_some() && t != Some(NodeType::DocumentNode) && &n != node {
1476      if !can_auto_close(&n) {
1477        cant_close.push(n.clone());
1478      }
1479      match n.get_parent() {
1480        Some(parent) => {
1481          n = parent;
1482          t = n.get_type();
1483        },
1484        None => {
1485          t = None; // detached node — stop walking
1486        },
1487      }
1488    }
1489
1490    if t == Some(NodeType::DocumentNode) || t.is_none() {
1491      // Didn't find $qname at all!!
1492      if strict {
1493        let qname = get_node_qname(node);
1494        arena::with(qname, |qname_str| {
1495          let message = s!(
1496            "Attempt to close {}, which isn't open. Currently in {:?}",
1497            qname_str,
1498            self.get_insertion_context(None)?
1499          );
1500          {
1501            Error!("malformed", qname_str, message)
1502          };
1503          Ok(())
1504        })?;
1505      }
1506    } else {
1507      // Found node.
1508      // Intervening non-auto-closeable nodes!!
1509      if !cant_close.is_empty() {
1510        model::with_node_qname(node, |qname| {
1511          let message = s!(
1512            "Closing {} whose open descendents do not auto-close. Descendents are {}",
1513            qname,
1514            cant_close
1515              .iter()
1516              .map(Node::get_name)
1517              .collect::<Vec<String>>()
1518              .join(", ")
1519          );
1520          if strict {
1521            Error!("malformed", qname, message);
1522          } else {
1523            Info!("malformed", qname, message);
1524          }
1525          Ok(())
1526        })?;
1527      }
1528      self.close_node_internal(node)?;
1529    }
1530    Ok(())
1531  }
1532
1533  /// Like [`Self::get_tag_action_list`], but keeps the `_Late` bucket
1534  /// separate. Streaming needs the split: a root's early/normal hooks
1535  /// (structural — the pending-resource drain) run at open as usual, while
1536  /// its `_late` hooks (semantically "with digestion complete" — frontmatter
1537  /// placement, root classes) are deferred to end-of-digestion.
1538  pub fn get_tag_action_list_parts(
1539    &self,
1540    tag: SymStr,
1541    when: TagOptionName,
1542  ) -> (Vec<TagConstructionClosure>, Vec<TagConstructionClosure>) {
1543    use self::tag::TagOptionName::*;
1544    let (when_early, when_late) = match when {
1545      AfterOpen => (Some(AfterOpenEarly), Some(AfterOpenLate)),
1546      AfterClose => (Some(AfterCloseEarly), Some(AfterCloseLate)),
1547      _ => (None, None),
1548    };
1549    let mut prompt = Vec::new();
1550    let mut late = Vec::new();
1551    state::with_tag_property(tag, |tag_hash| {
1552      state::with_tag_property(pin!("ltx:*"), |all_hash| {
1553        let collect =
1554          |bucket: &mut Vec<TagConstructionClosure>, opts: Option<&tag::TagOptions>, key| {
1555            if let Some(v) = opts.and_then(|o| o.get(key)) {
1556              bucket.extend(v.iter().cloned());
1557            }
1558          };
1559        if let Some(when0) = &when_early {
1560          collect(&mut prompt, tag_hash, when0);
1561          collect(&mut prompt, all_hash, when0);
1562        }
1563        collect(&mut prompt, tag_hash, &when);
1564        collect(&mut prompt, all_hash, &when);
1565        if let Some(when1) = &when_late {
1566          collect(&mut late, tag_hash, when1);
1567          collect(&mut late, all_hash, when1);
1568        }
1569      });
1570    });
1571    (prompt, late)
1572  }
1573
1574  /// get the actions that should be performed on afterOpen or afterClose
1575  pub fn get_tag_action_list(
1576    &self,
1577    tag: SymStr,
1578    when: TagOptionName,
1579  ) -> Vec<TagConstructionClosure> {
1580    use self::tag::TagOptionName::*;
1581    // my ($p, $n) = (undef, $tag);
1582    // if ($tag =~ /^([^:]+):(.+)$/) {
1583    //   ($p, $n) = ($1, $2); }
1584    let mut when_early = None;
1585    let mut when_late = None;
1586
1587    match when {
1588      AfterOpen => {
1589        when_early = Some(AfterOpenEarly);
1590        when_late = Some(AfterOpenLate);
1591      },
1592      AfterClose => {
1593        when_early = Some(AfterCloseEarly);
1594        when_late = Some(AfterCloseLate);
1595      },
1596      _ => {},
1597    };
1598
1599    let mut actions = Vec::new();
1600    // Borrow the per-tag and `ltx:*` option hashes in place instead of cloning
1601    // the whole `TagOptions` map twice per call (this runs on every element
1602    // open/close — ~8.8M calls across the witness corpus). We only need to copy
1603    // the matched action lists, and those hold `Rc<>` closures, so cloning each
1604    // is just a pointer + refcount bump. `with_tag_property` returns `None` for
1605    // an absent tag, equivalent to an empty `TagOptions` for a read (the only
1606    // difference vs `get_tag_property` is it skips vivifying an empty default,
1607    // which has no observable effect).
1608    state::with_tag_property(tag, |tag_hash| {
1609      state::with_tag_property(pin!("ltx:*"), |all_hash| {
1610        let mut collect = |opts: Option<&tag::TagOptions>, key: &TagOptionName| {
1611          if let Some(v) = opts.and_then(|o| o.get(key)) {
1612            actions.extend(v.iter().cloned());
1613          }
1614        };
1615        if let Some(when0) = when_early {
1616          collect(tag_hash, &when0);
1617          // ns_hash TODO
1618          collect(all_hash, &when0);
1619        }
1620        collect(tag_hash, &when);
1621        // ns_hash TODO
1622        collect(all_hash, &when);
1623        if let Some(when1) = when_late {
1624          collect(tag_hash, &when1);
1625          // ns_hash TODO
1626          collect(all_hash, &when1);
1627        }
1628      });
1629    });
1630    // return (
1631    //   (($v = $$taghash{$when0}) ? @$v : ()),
1632    //   (($v = $$nshash{$when0})  ? @$v : ()),
1633    //   (($v = $$allhash{$when0}) ? @$v : ()),
1634    //   (($v = $$taghash{$when})  ? @$v : ()),
1635    //   (($v = $$nshash{$when})   ? @$v : ()),
1636    //   (($v = $$allhash{$when})  ? @$v : ()),
1637    //   (($v = $$taghash{$when1}) ? @$v : ()),
1638    //   (($v = $$nshash{$when1})  ? @$v : ()),
1639    //   (($v = $$allhash{$when1}) ? @$v : ()),
1640    //   );
1641    actions
1642  }
1643
1644  pub fn serialize_to_string(&self) -> String {
1645    // This line is to use libxml2's built-in serializer w/indentation heuristic.
1646    // Apparently, libxml2 is giving us "binary" or byte strings which we'd prefer
1647    // to have as text. return decode('UTF-8',
1648    // $self->getDocument->toString($format)); } This uses our own serializer
1649    // emulating libxml2's heuristic indentation.
1650    // This uses our own serializer with the correct schema-based indentation rules:
1651    // noindent_children=true when the element can contain #PCDATA per the schema.
1652    let result = self.serialize_aux(&self.document.as_node(), 0, false, false);
1653    // Trim trailing newline (the root element adds \n after </document>
1654    // but Perl doesn't include it)
1655    result.trim_end_matches('\n').to_string() + "\n"
1656  }
1657
1658  /// We ought to try for something close to C14N (<http://www.w3.org/TR/xml-c14n>),
1659  /// but keep XML declaration, comments and don't convert empty elements.
1660  pub fn serialize_aux(
1661    &self,
1662    node: &Node,
1663    depth: usize,
1664    noindent: bool,
1665    heuristic: bool,
1666  ) -> String {
1667    let mut serialized = String::new();
1668    self.serialize_into(&mut serialized, node, depth, noindent, heuristic);
1669    serialized
1670  }
1671
1672  /// Splice a processed segment's text into `out`, resolving NESTED
1673  /// placeholders recursively (final assembly only). A segment spilled by a
1674  /// closing ancestor keeps its children's `<_spilled_ ref=…/>` markers as
1675  /// literal elements — inlining their text at spill time rebuilt multi-GB
1676  /// segments that pass 2 could not re-materialize. Each literal marker
1677  /// occupies either a full line (`indent + element + newline`, the
1678  /// indenting serialization) or an exact element span (noindent contexts);
1679  /// the inner segment text carries its own indentation/newline, so the
1680  /// marker's surrounding whitespace is dropped — but ONLY when the marker
1681  /// is line-positioned, so content spaces in noindent contexts survive.
1682  /// `<` is escaped everywhere in serialized text/attribute content, so the
1683  /// scan cannot match anything but real markers.
1684  fn splice_segment_text(&self, out: &mut String, text: &str) {
1685    let mut rest = text;
1686    while let Some(pos) = rest.find("<_spilled_ ") {
1687      let (before, from) = rest.split_at(pos);
1688      let Some(elem_end) = from.find("/>").map(|e| e + 2) else {
1689        break; // malformed marker: emit as-is below
1690      };
1691      let elem = &from[..elem_end];
1692      let seg_opt = elem
1693        .split_once("ref=\"")
1694        .and_then(|(_, tail)| tail.split_once('"'))
1695        .and_then(|(num, _)| num.parse::<u32>().ok())
1696        .map(crate::sxml::SegmentId);
1697      let inner = seg_opt.and_then(|seg| {
1698        self
1699          .spill_store
1700          .as_ref()
1701          .and_then(|store| store.read_segment(seg).ok())
1702      });
1703      let Some(inner) = inner else {
1704        emit_error(
1705          "spill",
1706          "unresolved",
1707          &format!("a nested disk-staged segment could not be spliced back ({elem})"),
1708        );
1709        out.push_str(before);
1710        out.push_str("<!-- latexml-oxide: LOST staged segment -->\n");
1711        rest = &from[elem_end..];
1712        continue;
1713      };
1714      // Drop the marker's own line whitespace when line-positioned.
1715      let mut cut = before.len();
1716      while cut > 0 && before.as_bytes()[cut - 1] == b' ' {
1717        cut -= 1;
1718      }
1719      let line_positioned = cut == 0 || before.as_bytes()[cut - 1] == b'\n';
1720      if line_positioned {
1721        out.push_str(&before[..cut]);
1722      } else {
1723        out.push_str(before);
1724      }
1725      self.splice_segment_text(out, &inner);
1726      rest = &from[elem_end..];
1727      if line_positioned && rest.starts_with('\n') {
1728        rest = &rest[1..];
1729      }
1730    }
1731    out.push_str(rest);
1732  }
1733
1734  /// Buffer-threaded core of [`serialize_aux`]. Writes the subtree directly into
1735  /// `out` instead of building (and re-copying) a fresh `String` per node — the
1736  /// recursive return-a-String pattern was the single largest byte source in the
1737  /// allocation profile (~2.8 GB across the witness corpus). Output is identical.
1738  fn serialize_into(
1739    &self,
1740    out: &mut String,
1741    node: &Node,
1742    depth: usize,
1743    noindent: bool,
1744    heuristic: bool,
1745  ) {
1746    // Push `depth` levels of two-space indent without the `"  ".repeat(depth)`
1747    // allocation the old code paid on every call (including text nodes).
1748    fn push_indent(out: &mut String, depth: usize) {
1749      for _ in 0..depth {
1750        out.push_str("  ");
1751      }
1752    }
1753    // `spill_flat` means "emit no decorative whitespace", which is exactly what
1754    // `noindent` already means at every emission site — so fold it in ONCE here
1755    // rather than testing it at each. It suppresses formatting only; the
1756    // schema-driven `noindent_children` still governs mixed content, whose
1757    // whitespace is meaningful and was never indented anyway.
1758    let noindent = noindent || self.spill_flat;
1759
1760    match node.get_type() {
1761      Some(NodeType::DocumentNode) => {
1762        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1763        if let Some(child) = node.get_first_child() {
1764          self.serialize_into(out, &child, depth, noindent, heuristic);
1765          let mut current_child = child;
1766          while let Some(sibling) = current_child.get_next_sibling() {
1767            self.serialize_into(out, &sibling, depth, noindent, heuristic);
1768            current_child = sibling;
1769          }
1770        }
1771      },
1772      Some(NodeType::ElementNode) => {
1773        // Get the qualified name (prefix:localname) for namespace-prefixed elements
1774        let local_name = node.get_name();
1775        // Streaming assembly: a spill placeholder serializes as the processed
1776        // segment's text, spliced verbatim from disk (same depth/noindent by
1777        // construction — the segment was serialized with the exact values this
1778        // recursion carries here). A placeholder that cannot be resolved is
1779        // LOST CONTENT: flag it loudly in the log AND the output, never drop
1780        // it silently (fail toward flagging).
1781        if local_name == SPILL_PLACEHOLDER && !self.literal_placeholders {
1782          let resolved = self
1783            .spill_store
1784            .as_ref()
1785            .zip(node.get_attribute("ref"))
1786            .and_then(|(store, r)| {
1787              let seg = crate::sxml::SegmentId(r.parse::<u32>().ok()?);
1788              store.read_segment(seg).ok()
1789            });
1790          match resolved {
1791            Some(text) => self.splice_segment_text(out, &text),
1792            None => {
1793              // `Error!` can early-return and this serializer returns `()`,
1794              // so raise through the logger; the marker keeps the loss
1795              // visible in the output itself.
1796              emit_error(
1797                "spill",
1798                "unresolved",
1799                &format!(
1800                  "a disk-staged segment could not be spliced back (ref={:?})",
1801                  node.get_attribute("ref")
1802                ),
1803              );
1804              out.push_str("<!-- latexml-oxide: LOST staged segment -->\n");
1805            },
1806          }
1807          return;
1808        }
1809        // (a literal_placeholders doc falls through: the placeholder is an
1810        // ordinary childless element and serializes as itself)
1811        let tag = if let Some(ns) = node.get_namespace() {
1812          let prefix = ns.get_prefix();
1813          if prefix.is_empty() {
1814            local_name
1815          } else {
1816            s!("{}:{}", prefix, local_name)
1817          }
1818        } else {
1819          local_name
1820        };
1821        let children = node.get_child_nodes();
1822        let mut open_tag = s!("<{tag}");
1823
1824        let nsnodes = node.get_namespace_declarations();
1825        for ns in nsnodes {
1826          let prefix = ns.get_prefix();
1827          let prefix_declaration = if prefix.is_empty() {
1828            s!("xmlns")
1829          } else {
1830            s!("xmlns:{}", prefix)
1831          };
1832          let href = ns.get_href();
1833          write!(open_tag, " {prefix_declaration}=\"{href}\"").ok();
1834        }
1835
1836        let anodes = node.get_attributes();
1837        // `get_attributes()` reports LOCAL names, so an `xml:id` attribute comes
1838        // back keyed as "id" -- by name alone indistinguishable from a genuine
1839        // plain `id`, which the model really does declare (see
1840        // `LaTeXML-bib.rnc:335,353`: `ltx:bib-identifier/@id`,
1841        // `ltx:bib-review/@id`). `get_node_document_qname` resolves the
1842        // attribute's NAMESPACE, so it returns "xml:id" for the former and "id"
1843        // for the latter; SERIALIZE that qname. Ordering stays keyed on the
1844        // local name, which is what the goldens encode (`xml:lang` files under
1845        // "lang", so it precedes `role`), with `xml:id` forced last -- the
1846        // previous code got that by appending it separately, which is also what
1847        // rewrote every plain `id` into an invalid-NCName `xml:id`
1848        // (witness arXiv 2508.17585).
1849        let mut anodes_keys: Vec<(SymStr, &String)> = anodes
1850          .keys()
1851          .map(|key| {
1852            let qname = node
1853              .get_attribute_node(key)
1854              .map(|a| model::get_node_document_qname(&a))
1855              .unwrap_or_else(|| arena::pin(key));
1856            (qname, key)
1857          })
1858          .collect();
1859        // Rank: xmlns:* declarations first (matching Perl's output order), then
1860        // the ordinary attributes by local name, then xml:id last.
1861        let rank = |qname: SymStr, key: &String| -> u8 {
1862          if key.starts_with("xmlns:") {
1863            0
1864          } else if arena::with(qname, |q| q == "xml:id") {
1865            2
1866          } else {
1867            1
1868          }
1869        };
1870        anodes_keys.sort_by(|(a_sym, a_key), (b_sym, b_key)| {
1871          rank(*a_sym, a_key)
1872            .cmp(&rank(*b_sym, b_key))
1873            .then_with(|| a_key.cmp(b_key))
1874        });
1875        for (key_sym, key) in anodes_keys {
1876          // Reuse the value already in `anodes` instead of re-fetching it with
1877          // `get_property` — `get_attributes()` read each value from the
1878          // attribute node directly, so a by-name `xmlGetProp` re-scan (plus a
1879          // fresh `CString` and result `String`) here is pure redundant FFI
1880          // churn (top `get_property` caller in the alloc profile).
1881          let val_serialized = serialize_attr(anodes.get(key).map(String::as_str).unwrap_or(""));
1882          arena::with(key_sym, |key_str| {
1883            write!(open_tag, " {key_str}=\"{val_serialized}\"")
1884          })
1885          .ok();
1886        }
1887
1888        let noindent_children: bool = if heuristic {
1889          // libxml2's heuristic: inline (noindent) if ANY direct child is a text node.
1890          // Crucially, this does NOT propagate the parent's noindent — each element
1891          // independently checks its own children for text nodes.
1892          children
1893            .iter()
1894            .any(|e| e.get_type() == Some(NodeType::TextNode))
1895        } else {
1896          // This is the "Correct" way to determine whether to add indentation
1897          let node_qname = get_node_qname(node);
1898          model::can_contain_sym(node_qname, pin!("#PCDATA"))
1899        };
1900
1901        let noindent_children = noindent_children || self.spill_flat;
1902        if !noindent {
1903          push_indent(out, depth);
1904        }
1905        out.push_str(&open_tag);
1906        // Perl serializes elements with children (including empty text nodes) as
1907        // <tag>...</tag>, and truly childless elements as <tag/>. Match this behavior.
1908        if !children.is_empty() {
1909          // with contents.
1910          out.push('>');
1911          if !noindent_children {
1912            out.push('\n');
1913          }
1914          for child in children {
1915            self.serialize_into(out, &child, depth + 1, noindent_children, heuristic);
1916          }
1917          if !noindent_children {
1918            push_indent(out, depth);
1919          }
1920          write!(out, "</{tag}>").ok();
1921        } else {
1922          // empty element.
1923          out.push_str("/>");
1924        }
1925        if !noindent {
1926          out.push('\n');
1927        }
1928      },
1929      Some(NodeType::TextNode) => {
1930        out.push_str(&serialize_string(&node.get_content()));
1931      },
1932      Some(NodeType::PiNode) => {
1933        // should code this by hand, as well...
1934        if !noindent {
1935          push_indent(out, depth);
1936        }
1937        out.push_str(&self.document.node_to_string(node));
1938        if !noindent {
1939          out.push('\n');
1940        }
1941      },
1942      Some(NodeType::CommentNode) => {
1943        write!(out, "<!-- {}-->", serialize_string(&node.get_content())).ok();
1944      },
1945      _ => {},
1946    }
1947  }
1948
1949  /// Move the current insertion point to `node` — Perl `Document.pm:setNode`
1950  /// L74-87.
1951  ///
1952  /// A document fragment is not itself an insertion point: as in Perl, a
1953  /// single-child fragment is descended into, and a fragment with several
1954  /// children is an error (reported, not fatal — see the body comment on why
1955  /// this returns `()`).
1956  pub fn set_node(&mut self, node: &Node) {
1957    // Perl Document.pm:setNode L74-87: if the candidate is a
1958    // DOCUMENT_FRAG_NODE, validate that it has exactly one child and
1959    // descend to that child. The original Rust port had this check
1960    // commented-out with a wrong-node-type marker (`DocumentNode`
1961    // instead of `DocumentFragNode`); revived with the correct enum.
1962    let mut chosen = node.clone();
1963    if chosen.get_type() == Some(NodeType::DocumentFragNode) {
1964      let children = chosen.get_child_nodes();
1965      // Wrap the Error!/note_status side-effects in an IIFE to swallow the
1966      // `Result<()>` the macro returns (it can early-return Err if the
1967      // MAX_ERRORS cap is hit). `set_node` returns `()` and is called from
1968      // 32 call-sites; threading `Result` through all of them is out of
1969      // scope for an audit-fix. The hot path stays the same; the rare
1970      // hit-the-cap case loses one escape attempt but the next Error!
1971      // anywhere else will trigger the cap regardless.
1972      let _ = (|| -> Result<()> {
1973        if children.len() > 1 {
1974          Error!(
1975            "unexpected",
1976            "multiple-nodes",
1977            "Cannot set insertion point to a DOCUMENT_FRAG_NODE"
1978          );
1979        } else if children.is_empty() {
1980          Error!(
1981            "unexpected",
1982            "empty-nodes",
1983            "Cannot set insertion point to an empty DOCUMENT_FRAG_NODE"
1984          );
1985        }
1986        Ok(())
1987      })();
1988      if let Some(first) = children.into_iter().next() {
1989        chosen = first;
1990      }
1991    }
1992    self.node = chosen;
1993  }
1994
1995  // Internals
1996  /// Scan for RDFa attributes in the document and set the `prefix` attribute
1997  /// on the root element based on which RDFa prefixes are actually used.
1998  fn set_rdfa_prefixes(&mut self) {
1999    // Collect the RDFa prefix mapping from state
2000    let prefix_map: HashMap<String, String> = state::with_mapping_keys("RDFa_prefixes", |keys| {
2001      let mut map = HashMap::default();
2002      for key_sym in keys {
2003        let key_str = arena::to_string(key_sym);
2004        if let Some(stored) = state::lookup_mapping("RDFa_prefixes", &key_str) {
2005          map.insert(key_str, stored.to_string());
2006        }
2007      }
2008      map
2009    });
2010    if prefix_map.is_empty() {
2011      return;
2012    }
2013
2014    let non_rdf_prefixes: HashSet<&str> = ["http", "https", "ftp"].iter().copied().collect();
2015    let rdf_term_attrs = [
2016      "about", "resource", "property", "typeof", "rel", "rev", "datatype",
2017    ];
2018
2019    // Build XPath to find elements with any RDFa term attribute
2020    let xpath = format!(
2021      "descendant::*[{}]",
2022      rdf_term_attrs
2023        .iter()
2024        .map(|a| format!("@{a}"))
2025        .collect::<Vec<_>>()
2026        .join(" or ")
2027    );
2028
2029    let mut used_prefixes: BTreeSet<String> = BTreeSet::new();
2030
2031    let nodes = self.findnodes(&xpath, None);
2032    for node in &nodes {
2033      for attr_name in &rdf_term_attrs {
2034        if let Some(value) = node.get_attribute(attr_name) {
2035          for term in value.split_whitespace() {
2036            if let Some(colon_pos) = term.find(':') {
2037              let prefix = &term[..colon_pos];
2038              if !non_rdf_prefixes.contains(prefix) && prefix_map.contains_key(prefix) {
2039                used_prefixes.insert(prefix.to_string());
2040              }
2041            }
2042          }
2043        }
2044      }
2045    }
2046
2047    for prefix in &self.extra_rdfa_prefixes {
2048      if !non_rdf_prefixes.contains(prefix.as_str()) && prefix_map.contains_key(prefix) {
2049        used_prefixes.insert(prefix.clone());
2050      }
2051    }
2052
2053    if !used_prefixes.is_empty()
2054      && let Some(mut root) = self.document.get_root_element()
2055    {
2056      let prefix_str = used_prefixes
2057        .iter()
2058        .map(|p| format!("{}: {}", p, prefix_map[p]))
2059        .collect::<Vec<_>>()
2060        .join(" ");
2061      let _ = root.set_attribute("prefix", &prefix_str);
2062    }
2063  }
2064
2065  pub fn insert_math_token(
2066    &mut self,
2067    text: &str,
2068    mut attributes: HashMap<String, String>,
2069    font_opt: Option<&Font>,
2070  ) -> Result<Node> {
2071    // Perf: avoid allocating the "role" String key unless the entry is missing.
2072    // HashMap::entry() takes an owned K, which forces allocation even on the
2073    // common path where "role" is already present.
2074    if !attributes.contains_key("role") {
2075      attributes.insert(String::from("role"), String::from("UNKNOWN"));
2076    }
2077    // Remove internal-only properties that should not become XML attributes.
2078    // In Perl, these are filtered by canHaveAttribute (model validation),
2079    // but we filter them explicitly here.
2080    attributes.remove("mode");
2081    attributes.remove("isMath");
2082    attributes.remove("cached_width");
2083    attributes.remove("cached_height");
2084    attributes.remove("cached_depth");
2085    // attributes.remove("stretchy");
2086
2087    let is_space = attributes.contains_key("isSpace");
2088    let qname = if is_space {
2089      MATH_HINT_NAME
2090    } else {
2091      MATH_TOKEN_NAME
2092    };
2093    let cur_qname = get_node_qname(&self.node);
2094    let text = if is_space && !text.is_empty() && text.chars().all(|c| c.is_whitespace()) {
2095      "" // Make empty hint, of only spaces
2096    } else {
2097      text
2098    };
2099    if qname == MATH_TOKEN_NAME && cur_qname == pin!("ltx:XMTok") {
2100      // Already INSIDE a token!
2101      if !text.is_empty() {
2102        self.open_math_text_internal(text)?;
2103      }
2104    } else {
2105      let mut node = self.open_element(qname, Some(attributes), None)?;
2106      // let tbox  = $attributes{_box} || $LaTeXML::BOX;
2107      let font = match font_opt {
2108        Some(f) => f.clone(),
2109        None => match self.box_to_absorb {
2110          Some(ref tbox) => match tbox.get_font()? {
2111            Some(f) => (*f).clone(),
2112            None => Font::math_default(), // should never happen?
2113          },
2114          None => Font::math_default(), // should never happen?
2115        },
2116      };
2117      self.set_node_font(&mut node, &font)?;
2118      if let Some(ref digested) = self.box_to_absorb {
2119        // TODO: The Rc<Digested> node boxes still have some way to go until they are fully
2120        // ergonomic...
2121        self.set_node_box(&node, digested.clone());
2122      }
2123      if !text.is_empty() {
2124        self.open_math_text_internal(text)?;
2125      }
2126      self.close_node_internal(&node)?; // Should be safe.
2127    }
2128    Ok(self.node.clone())
2129  }
2130
2131  /// Create a libxml2 comment node and attach it to `anchor` according to
2132  /// the supplied Placement. Called from `insert_comment`. Thin wrapper
2133  /// around the safe rust-libxml API (`Node::new_comment` +
2134  /// `add_child`/`add_prev_sibling`) — earlier versions of this method
2135  /// made direct FFI calls, which is now forbidden by the D3b policy.
2136  fn add_comment(document: &XmlDoc, anchor: &Node, comment_text: &str, placement: Placement_) {
2137    let Ok(mut comment) = Node::new_comment(comment_text, document) else {
2138      return;
2139    };
2140    let mut anchor = anchor.clone();
2141    match placement {
2142      Placement_::AppendChild => {
2143        let _ = anchor.add_child(&mut comment);
2144      },
2145      Placement_::PrevSibling => {
2146        let _ = anchor.add_prev_sibling(&mut comment);
2147      },
2148    }
2149  }
2150
2151  /// Insert a new comment, or append to previous comment.
2152  /// Does NOT move the current insertion point to the Comment,
2153  /// but may move up past a text node.
2154  /// Perl: Document.pm lines 678-698
2155  pub fn insert_comment(&mut self, text: &str) -> Result<Node> {
2156    let trimmed = text.trim_end();
2157    let clean = DASHES_RE.replace_all(trimmed, "__");
2158    // Perl does NOT close the text node here — it uses getElement() to find
2159    // the nearest element, then inserts the comment relative to element children.
2160    // This preserves self.node as the current text node, so subsequent text
2161    // appends correctly and ligatures fire on the full text run.
2162
2163    let comment_text = s!(" {} ", clean);
2164
2165    if self.node.get_type() == Some(NodeType::DocumentNode) {
2166      Self::add_comment(
2167        &self.document,
2168        &self.node,
2169        &comment_text,
2170        Placement_::AppendChild,
2171      );
2172    } else {
2173      if let Some(node) = self.get_element() {
2174        // Get the nearest element node (Perl: getElement)
2175        let prev = node.get_last_child();
2176        let prevtype = prev.as_ref().and_then(|n| n.get_type());
2177
2178        if prevtype == Some(NodeType::CommentNode) {
2179          // Merge with previous comment
2180          if let Some(mut prev_comment) = prev {
2181            let existing = prev_comment.get_content();
2182            let merged = s!("{}\n     {} ", existing, clean);
2183            prev_comment.set_content(&merged).ok();
2184          }
2185        } else if prevtype == Some(NodeType::TextNode) {
2186          let prev_node = prev.unwrap();
2187          // If the node before the text is already a comment, just append new comment
2188          // Otherwise, insert before the text to avoid splitting text runs
2189          let before_text = prev_node.get_prev_sibling();
2190          let before_is_comment =
2191            before_text.as_ref().and_then(|n| n.get_type()) == Some(NodeType::CommentNode);
2192
2193          if before_is_comment {
2194            Self::add_comment(
2195              &self.document,
2196              &node,
2197              &comment_text,
2198              Placement_::AppendChild,
2199            );
2200          } else {
2201            Self::add_comment(
2202              &self.document,
2203              &prev_node,
2204              &comment_text,
2205              Placement_::PrevSibling,
2206            );
2207          }
2208        } else {
2209          Self::add_comment(
2210            &self.document,
2211            &node,
2212            &comment_text,
2213            Placement_::AppendChild,
2214          );
2215        }
2216      }
2217    }
2218    Ok(self.node.clone())
2219  }
2220
2221  // **********************************************************************
2222  // Middle level, mostly public, API.
2223  // Handlers for various construction operations.
2224  // General naming: 'open' opens a node at current pos and sets it to current,
2225  // 'close' closes current node(s), inserts opens & closes, ie. w/o moving
2226  // current
2227
2228  // Tricky: Insert some text in a particular font.
2229  // We need to find the current effective -- being the closest  _declared_ font,
2230  // (ie. it will appear in the elements attributes).  We may also want
2231  // to open/close some elements in such a way as to minimize the font switchiness.
2232  // I guess we should only open/close "text" elements, though.
2233  // [Actually, we'd like the user to _declare_ what element to use....
2234  //  I don't like having "text" built in here!
2235  //  AND, we've assumed that "font" names the relevant attribute!!!]
2236
2237  pub fn open_text(&mut self, text: &str, font: &Font) -> Result<Option<Node>> {
2238    let node_type = self.node.get_type();
2239    {
2240      // Ignore initial whitespace
2241      if (text.is_empty() || ONLY_SPACE_RE.is_match(text))
2242        && (node_type == Some(NodeType::DocumentNode)
2243          || (node_type == Some(NodeType::ElementNode) && !can_contain(&self.node, "#PCDATA")))
2244      {
2245        // ...EXCEPT whitespace that is verbatim CONTENT rather than ignorable
2246        // lexical padding: (a) TYPEWRITER-font spaces — fancyvrb/fvextra map
2247        // every verbatim space to a digested space box (`\FV@Space` →
2248        // `\FV@SpaceCatTen`, a braced ordinary space), so the line-LEADING
2249        // indentation of a code block arrives here at a not-yet-opened
2250        // paragraph, and dropping it deleted JSON-schema indentation and
2251        // collapsed space-only verbatim lines out of the height budget
2252        // (2605.00468 Prompt boxes: flush-left schemas, 15-33px frame
2253        // spills); (b) an EXPLICIT control space `\ ` (Box name="space"),
2254        // which TeX always typesets. Line-leading cat-10 SOURCE spaces never
2255        // reach this point (the mouth's state-N skip eats them), so this
2256        // does not resurrect source-formatting whitespace. Same-host Perl
2257        // cannot convert the fancyvrb constructs at all (raw
2258        // fvextra+breaklines exceeds 7 min on a 6-line file) — surpass-Perl
2259        // scope, not a parity break. Fall through: the normal insertion
2260        // machinery auto-opens the paragraph and keeps the spaces as text.
2261        let explicit_space = node_type == Some(NodeType::ElementNode)
2262          && !text.is_empty()
2263          && (font.family.as_deref() == Some("typewriter")
2264            || self.box_to_absorb.as_ref().is_some_and(|b| {
2265              b.get_property("name")
2266                .is_some_and(|n| n.to_string() == "space")
2267            }));
2268        if !explicit_space {
2269          return Ok(None);
2270        }
2271        // open_text_internal has its own whitespace-only gate (mirroring
2272        // Perl L1146: insert only if `\S` or direct #PCDATA) — hand it the
2273        // decision so the spaces survive the auto-open.
2274        self.verbatim_space_pending = true;
2275      }
2276    }
2277    if matches!(&font.family.as_deref(), Some("nullfont")) {
2278      return Ok(None);
2279    };
2280    Debug!(
2281      "document",
2282      "open_text",
2283      s!(
2284        "Insert text {:?} at {:?}",
2285        text,
2286        self.document.node_to_string(&self.node)
2287      )
2288    );
2289
2290    // Get the desired font attributes, particularly the desired element
2291    // (usually ltx:text, but let Font override, eg for \emph)
2292    let declared_font = self.get_node_font(&self.node);
2293    let pending_declaration = font.relative_to(declared_font);
2294    let elementname = match pending_declaration.get("element") {
2295      Some((k, _v)) => k,
2296      None => FONT_ELEMENT_NAME,
2297    };
2298    let element_sym = arena::pin(elementname);
2299    // If not at document begin. And not appending text in same font.
2300    //
2301    // Defense-in-depth (issue #217): a current TextNode should always have a
2302    // parent element, so this used to be `self.node.get_parent().unwrap()`.
2303    // Match the `None` instead of unwrapping — if the current node were ever a
2304    // detached text node, skip this un-anchorable insert rather than panic.
2305    // (The macOS corruption that could produce such a node was root-caused
2306    // and fixed in open_text_internal's text-merge detection.)
2307    let text_same_font = if node_type == Some(NodeType::TextNode) {
2308      match self.node.get_parent() {
2309        Some(parent) => font.distance(self.get_node_font(&parent)) == 0,
2310        None => return Ok(None),
2311      }
2312    } else {
2313      false
2314    };
2315    if node_type != Some(NodeType::DocumentNode) && !text_same_font {
2316      // then we'll need to do some open/close to get fonts matched.
2317      let node = self.close_text_internal()?; // Close text node, if any.
2318      let mut bestdiff = 99;
2319      let rc_node = Rc::new(node);
2320      let mut closeto: Rc<Node> = Rc::clone(&rc_node);
2321      let mut n: Rc<Node> = Rc::clone(&rc_node);
2322      while n.get_type() != Some(NodeType::DocumentNode) {
2323        let node_font = self.get_node_font(&n);
2324        let d = font.distance(node_font);
2325        if d < bestdiff {
2326          bestdiff = d;
2327          closeto = n.clone();
2328          if d == 0 {
2329            break;
2330          }
2331        }
2332        // Stop if not a font element, or if marked _noautoclose, or if
2333        // this is an explicit (non-fontswitch) text wrapper. A constructor-
2334        // opened `<ltx:text class='...'>` (e.g. `\uline{...}`) MUST NOT be
2335        // closed-out-of by a font-distance heuristic just because the parent
2336        // happens to score better — that produces an empty wrapper and
2337        // siblings the inner content (driver: 2402.16319 `\uline{\textbf{2}}`
2338        // inside `\sc` tabular). Only auto-opened fontswitch wrappers are
2339        // safe to walk past.
2340        if get_node_qname(&n) != element_sym
2341          || n.has_attribute("_noautoclose")
2342          || !n.has_attribute("_fontswitch")
2343        {
2344          break;
2345        }
2346        match n.get_parent() {
2347          Some(p) => n = Rc::new(p),
2348          None => break,
2349        }
2350      }
2351
2352      // Move to best starting point for this text.
2353      if *closeto != *rc_node {
2354        self.close_to_node(&closeto, false)?;
2355      }
2356      if bestdiff > 0 {
2357        // Open if needed.
2358        self.open_element(
2359          elementname,
2360          Some(string_map!("_fontswitch" => "true", "_autoopened" => "true")),
2361          Some(font),
2362        )?;
2363      }
2364    }
2365
2366    // Finally, insert the darned text.
2367    let outnode = self.open_text_internal(text)?;
2368    self.record_constructed_node(&outnode);
2369    Ok(Some(outnode))
2370  }
2371
2372  pub fn close_text_internal(&mut self) -> Result<Node> {
2373    if self.node.get_type() == Some(NodeType::TextNode) {
2374      // Current node is text?
2375      let parent = self.node.get_parent().unwrap();
2376      let font = self.get_node_font(&parent);
2377      let ocontent = self.node.get_content();
2378      let mut content = Cow::Borrowed(&ocontent);
2379      state::with_value("TEXT_LIGATURES", |value_opt| {
2380        if let Some(Stored::VecDequeStored(ligatures)) = value_opt {
2381          for stored_ligature in ligatures.iter() {
2382            if let Stored::Ligature(ligature) = stored_ligature {
2383              if let Some(ref font_test) = ligature.font_test
2384                && !(font_test)(font)
2385              {
2386                continue; // if the font test fails, skip the ligature
2387              }
2388              content = Cow::Owned((ligature.code.as_ref().unwrap())(&content));
2389            }
2390          }
2391        }
2392      });
2393      if *content != ocontent {
2394        self.node.set_content(&content)?;
2395      }
2396      self.node = parent.clone(); // Effectively closed (->setNode, but don't recurse)
2397      Ok(parent)
2398    } else {
2399      Ok(self.node.clone())
2400    }
2401  }
2402
2403  /// Close `node`, and any current nodes below it.
2404  /// No checking! Use this when you've already verified that `node` can be closed.
2405  /// and, of course, `node` must be current or some ancestor of it!!!
2406  pub fn close_node_internal(&mut self, node: &Node) -> Result<()> {
2407    let closeto = match node.get_parent() {
2408      Some(p) => p,
2409      None => {
2410        // Node has been detached — nothing to close up to.
2411        return Ok(());
2412      },
2413    };
2414    let mut n = self.close_text_internal()?; // Close any open text node.
2415    while n.get_type() == Some(NodeType::ElementNode) {
2416      self.close_element_at(&mut n)?;
2417      self.auto_collapse_children(&mut n)?;
2418      if *node == n {
2419        break;
2420      }
2421      match n.get_parent() {
2422        Some(parent) => {
2423          n = parent;
2424        },
2425        None => {
2426          // Node was detached during close/collapse — bail out safely.
2427          break;
2428        },
2429      }
2430    }
2431    self.set_node(&closeto);
2432    Ok(())
2433  }
2434
2435  /// Avoid redundant nesting of font switching elements:
2436  /// If we're closing a node that can take font switches and it contains
2437  /// a single FONT_ELEMENT_NAME node; pull it up.
2438  fn auto_collapse_children(&mut self, node: &mut Node) -> Result<()> {
2439    let qname = get_node_qname(node);
2440    if qname != pin!("ltx:_Capture_") {
2441      let mut c = node.get_child_nodes();
2442      // with single child, AND, $node can have all the attributes that the child has (but at least
2443      // "font") BUT, it isn"t being forced somehow
2444      if c.len() == 1
2445        && (get_node_qname(&c[0]) == pin!("ltx:text"))
2446        && model::can_have_attribute(qname, pin!("font"))
2447        && c[0]
2448          .get_attributes()
2449          .keys()
2450          .filter(|x| !x.starts_with('_'))
2451          .all(|v| {
2452            model::can_have_attribute(qname, arena::pin(v))
2453              && !(NON_MERGEABLE_ATTRIBUTES.contains(v.as_str()))
2454          })
2455        && !c[0].has_attribute("_force_font")
2456      {
2457        let c_first = c.pop().unwrap();
2458        let c_first_font = self.get_node_font(&c_first).clone();
2459        self.set_node_font(node, &c_first_font)?;
2460        for mut gc in c_first.get_child_nodes().into_iter() {
2461          gc.unlink();
2462          node.add_child(&mut gc)?;
2463        }
2464        // Re-record ids once, after ALL grandchildren are moved. Calling this
2465        // per-grandchild inside the loop re-scanned the whole growing `node`
2466        // subtree each iteration — O(G²) `descendant-or-self::*[@xml:id]` XPath
2467        // scans per merge (115k scans / ~50% of build wall on a PiCTeX paper).
2468        // record_id_with_node is idempotent for already-correct nodes and the
2469        // final set/document-order is identical, so one post-loop call is
2470        // output-equivalent. See PERFORMANCE.md.
2471        self.record_node_ids(node)?;
2472        // Merge the attributes from the child onto $node
2473        self.merge_attributes(&c_first, node, None)?;
2474        self.remove_node(c_first);
2475      }
2476    }
2477    Ok(())
2478  }
2479
2480  pub fn merge_attributes(
2481    &mut self,
2482    from: &Node,
2483    to: &mut Node,
2484    force: Option<&HashSet<&'static str>>,
2485  ) -> Result<()> {
2486    for (key, val) in from.get_attributes().iter() {
2487      // Skip internal attributes
2488      if key.starts_with('_') {
2489        continue;
2490      }
2491      // Normalize key: get_attributes() returns "id" for xml:id attributes.
2492      // Check both "xml:id" and bare "id" with XML namespace for the special case.
2493      let is_xml_id = key.as_str() == "xml:id"
2494        || (key.as_str() == "id"
2495          && from
2496            .get_attribute_ns("id", "http://www.w3.org/XML/1998/namespace")
2497            .is_some());
2498      let effective_key = if is_xml_id { "xml:id" } else { key.as_str() };
2499      let is_forced = force.is_some_and(|f| f.contains(effective_key));
2500      // Special case attributes
2501      if is_xml_id {
2502        // Use the replacement id. record_id_with_node returns a
2503        // deduplicated id when a DIFFERENT node already claims the
2504        // same one; must use the return value, not the original `val`.
2505        let to_has_id = to.has_attribute("xml:id")
2506          || to
2507            .get_attribute_ns("id", "http://www.w3.org/XML/1998/namespace")
2508            .is_some();
2509        if !to_has_id || is_forced {
2510          self.unrecord_id(val);
2511          let deduped = self.record_id_with_node(val, to);
2512          to.set_attribute("xml:id", &deduped)?;
2513        }
2514      } else if MERGE_ATTRIBUTE_SPACEJOIN.contains(key.as_str()) {
2515        self.add_ss_values(to, key, val)?;
2516      } else if MERGE_ATTRIBUTE_SEMICOLONJOIN.contains(key.as_str()) {
2517        if let Some(existing) = to.get_attribute(key) {
2518          let merged = format!("{existing}; {val}");
2519          to.set_attribute(key, &merged)?;
2520        } else {
2521          to.set_attribute(key, val)?;
2522        }
2523      } else if MERGE_ATTRIBUTE_SUMLENGTH.contains(key.as_str()) {
2524        if let Some(val2) = to.get_attribute(key) {
2525          // Parse and sum pt values
2526          let v1 = val.trim_end_matches("pt").parse::<f64>().unwrap_or(0.0);
2527          let v2 = val2.trim_end_matches("pt").parse::<f64>().unwrap_or(0.0);
2528          to.set_attribute(key, &format!("{}pt", v1 + v2))?;
2529        } else {
2530          to.set_attribute(key, val)?;
2531        }
2532      } else if !to.has_attribute(key) || is_forced {
2533        // Else if attribute not present on $to, or if we specifically override it, just copy
2534        to.set_attribute(key, val)?;
2535      }
2536    }
2537    Ok(())
2538  }
2539
2540  fn open_text_internal(&mut self, text: &str) -> Result<Node> {
2541    // Consume the verbatim-space handoff (see `verbatim_space_pending`);
2542    // taking it here keeps the flag from leaking into unrelated calls.
2543    let force_whitespace = std::mem::take(&mut self.verbatim_space_pending);
2544    if text.is_empty() {
2545      return Ok(self.node.clone());
2546    }
2547    // Sibling guard to open_math_text_internal: libxml's append_text uses
2548    // CString and panics on embedded NULs (forbidden in XML text per spec).
2549    // Strip them — and the rest of the XML-invalid control class — up-front
2550    // so all downstream append_text calls are safe and the output stays
2551    // well-formed (shared policy with `set_attribute`, see `xml_sanitize`).
2552    if let Cow::Owned(cleaned) = xml_sanitize(text) {
2553      self.verbatim_space_pending = force_whitespace; // re-arm for the recursion
2554      return self.open_text_internal(&cleaned);
2555    }
2556    if self.node.get_type() == Some(NodeType::TextNode) {
2557      // current node already is a text node.
2558      Debug!(
2559        "document",
2560        "open_text_internal",
2561        s!(
2562          "Appending text {:?} to {:?}",
2563          text,
2564          self.document.node_to_string(&self.node)
2565        )
2566      );
2567
2568      let parent = self.node.get_parent().unwrap();
2569      if self.box_to_absorb.is_some() && parent.get_attribute("_autoopened").is_some() {
2570        // Perl L1136-1137: appendNodeBox to accumulate boxes for autoopened elements
2571        let bta = self.box_to_absorb.clone().unwrap();
2572        self.append_node_box(&parent, &bta);
2573      }
2574      self.node.append_text(text)?;
2575    }
2576    // Perl lines 1139-1144: if lastChild is a comment node and its previous sibling is
2577    // a text node, swap them to avoid splitting text runs, then recurse.
2578    // This avoids libxml text-node merging which would bypass ligature processing.
2579    else if self.swap_comment_text_if_needed(text)? {
2580      // Handled by recursive call
2581    } else if HAS_NONSPACE_RE.is_match(text)
2582      || force_whitespace
2583      || can_contain(&self.node, "#PCDATA")
2584    {
2585      // or text allowed here
2586      let mut point = self.find_insertion_point("#PCDATA", None)?;
2587      // Perl L1149-1150: appendNodeBox for autoopened insertion points
2588      if self.box_to_absorb.is_some() && point.get_attribute("_autoopened").is_some() {
2589        let bta = self.box_to_absorb.clone().unwrap();
2590        self.append_node_box(&point, &bta);
2591      }
2592      Debug!(
2593        "document",
2594        "open_text_internal",
2595        s!(
2596          "Inserting text node for {:?} into {:?}",
2597          text,
2598          self.document.node_to_string(&point)
2599        )
2600      );
2601      let mut node = Node::new_text(text, &self.document)?;
2602      point.add_child(&mut node)?;
2603      // libxml2 MERGES adjacent text nodes: when `point`'s last child was
2604      // already a text node, `xmlAddChild` appends our content to it and
2605      // FREES the just-created `node`, leaving its wrapper dangling.
2606      //
2607      // The previous detection — `node.get_type().is_none()` — is a
2608      // use-after-free: it reads the freed node's `type` field. That is
2609      // *benign on glibc* (the freed slot still reads as the old/None type,
2610      // so the merge is detected), but **unsound on macOS libmalloc**, which
2611      // recycles/scribbles the freed slot so `get_type()` returns garbage
2612      // (EntityNode/ElementDecl/…) and the merge goes UNDETECTED — installing
2613      // a freed node as `self.node` and corrupting the current insertion
2614      // point (issue #217; the macOS-only worker-thread crashes).
2615      //
2616      // Detect the merge WITHOUT dereferencing the possibly-freed node:
2617      // after `add_child` our text is `point`'s last child in both cases
2618      // (the appended `node`, or the sibling it merged into). `Node`'s
2619      // `PartialEq` compares the stored `xmlNodePtr` values (no deref), so
2620      // this pointer-identity check is allocator-independent and UAF-safe.
2621      if point.get_last_child().as_ref() == Some(&node) {
2622        // `node` was appended (not merged) — it is live and current.
2623        self.set_node(&node);
2624      } else {
2625        // `node` was merged into a text sibling and freed — fall back to the
2626        // parent insertion point (matches the prior merged-case behavior).
2627        self.set_node(&point);
2628      }
2629    }
2630    Ok(self.node.clone())
2631  }
2632
2633  /// Perl lines 1139-1144: Avoid splitting text runs across comments.
2634  /// If the current node's lastChild is a comment and the previous sibling is a text node,
2635  /// swap them so the text node is last, set it as current, and recurse to append new text.
2636  /// Returns true if the swap+append was performed, false otherwise.
2637  fn swap_comment_text_if_needed(&mut self, text: &str) -> Result<bool> {
2638    if self.node.get_type() != Some(NodeType::ElementNode) {
2639      return Ok(false);
2640    }
2641    if let Some(last_child) = self.node.get_last_child()
2642      && last_child.get_type() == Some(NodeType::CommentNode)
2643      && let Some(mut prev_text) = last_child.get_prev_sibling()
2644      && prev_text.get_type() == Some(NodeType::TextNode)
2645    {
2646      // Swap: move text node after comment node
2647      let mut comment_node = last_child;
2648      comment_node.add_next_sibling(&mut prev_text)?;
2649      // Set current node to the moved text node and recurse. Use
2650      // pointer-identity, not the `prev_text` handle directly:
2651      // `add_next_sibling` of a text node can MERGE it into an adjacent
2652      // text sibling and FREE it (the same libxml2 hazard fixed in
2653      // open_text_internal — benign on glibc, a use-after-free on macOS
2654      // libmalloc, issue #217). The moved/merged text is `comment_node`'s
2655      // next sibling either way; if that is still `prev_text` it survived,
2656      // otherwise `prev_text` was freed — fall back to the live sibling.
2657      match comment_node.get_next_sibling() {
2658        Some(moved) => self.set_node(&moved),
2659        None => self.set_node(&prev_text),
2660      }
2661      self.open_text_internal(text)?;
2662      return Ok(true);
2663    }
2664    Ok(false)
2665  }
2666
2667  /// Perl: appendNodeBox — when material is added to an autoopened element,
2668  /// accumulate the record of boxes that created the node.
2669  /// Propagates up through autoopened ancestors.
2670  fn append_node_box(&mut self, node: &Node, thisbox: &Digested) {
2671    let mut node = node.clone();
2672    loop {
2673      let origbox = self.get_node_box(&node);
2674      if let Some(ref orig) = origbox {
2675        // Perl: ($box eq $origbox) || ($box eq ($origbox->unlist)[-1]) → skip (dedup)
2676        // Use pointer identity on the Rc-wrapped DigestedData
2677        let same_as_orig = std::ptr::eq(
2678          thisbox.data() as *const DigestedData,
2679          orig.data() as *const DigestedData,
2680        );
2681        let same_as_last = if !same_as_orig {
2682          if let DigestedData::List(list) = orig.data() {
2683            list
2684              .borrow()
2685              .boxes
2686              .last()
2687              .map(|b| {
2688                std::ptr::eq(
2689                  thisbox.data() as *const DigestedData,
2690                  b.data() as *const DigestedData,
2691                )
2692              })
2693              .unwrap_or(false)
2694          } else {
2695            false
2696          }
2697        } else {
2698          false
2699        };
2700        if !same_as_orig && !same_as_last {
2701          // Perl: List($origbox, $box, mode => $origbox->getProperty('mode'))
2702          let mode = orig.get_property("mode").and_then(|m| {
2703            if let Stored::String(s) = m.as_ref() {
2704              Some(*s)
2705            } else {
2706              None
2707            }
2708          });
2709          let mut new_list = List::new(vec![orig.clone(), thisbox.clone()]);
2710          if let Some(mode_str) = mode {
2711            new_list.properties.insert("mode", Stored::String(mode_str));
2712          }
2713          self.set_node_box(&node, new_list.into());
2714        }
2715      } else {
2716        self.set_node_box(&node, thisbox.clone());
2717      }
2718      // Propagate to autoopened ancestors
2719      match node.get_parent() {
2720        Some(parent)
2721          if parent.get_type() == Some(NodeType::ElementNode)
2722            && parent.get_attribute("_autoopened").is_some() =>
2723        {
2724          node = parent;
2725        },
2726        _ => break,
2727      }
2728    }
2729  }
2730
2731  // Question: Why do I have math ligatures handled within openMathText_internal,
2732  // but text ligatures handled within closeText_internal ???
2733
2734  /// Needed externally only for the binding generation
2735  fn open_math_text_internal(&mut self, text: &str) -> Result<Node> {
2736    // And if there's already text???
2737    let mut node = self.node.clone();
2738    // my $font = $self->getNodeFont($node);
2739    // libxml's append_text uses CString and panics on embedded NULs. NUL is
2740    // forbidden in XML text per spec anyway, so strip it (and the rest of the
2741    // XML-invalid control class — shared policy with `set_attribute`, see
2742    // `xml_sanitize`). Witness: astro-ph0202376 (a paper that produces math
2743    // tokens with \char0 / NUL bytes embedded in their content). Matches
2744    // Perl's libxml behavior which silently drops NULs in text content.
2745    node.append_text(&xml_sanitize(text))?;
2746    // print STDERR "Trying Math Ligatures at \"$string\"\n";
2747    if !state::get_nomathparse_flag() {
2748      self.apply_math_ligatures(&mut node)?;
2749    }
2750    Ok(node)
2751  }
2752
2753  // New strategy (but inefficient): apply ligatures until one succeeds,
2754  // then remove it, and repeat until ALL (remaining) fail.
2755  fn apply_math_ligatures(&mut self, node: &mut Node) -> Result<()> {
2756    let checked_out_ligatures = state::checkout_value("MATH_LIGATURES");
2757    if let Some(Stored::VecDequeStored(ref stored_ligatures)) = checked_out_ligatures {
2758      let mut ligatures = stored_ligatures.iter().collect::<VecDeque<_>>();
2759      while !ligatures.is_empty() {
2760        let mut matched = false;
2761        let mut next_ligatures = VecDeque::new();
2762        while !ligatures.is_empty() {
2763          let ligature_stored = ligatures.pop_front().unwrap();
2764          if let Stored::Ligature(ligature) = ligature_stored {
2765            if self.apply_math_ligature(node, ligature)? {
2766              next_ligatures.extend(ligatures.drain(..));
2767              matched = true;
2768              break;
2769            }
2770          } else {
2771            next_ligatures.push_back(ligature_stored);
2772          }
2773        }
2774        ligatures = next_ligatures;
2775        if !matched {
2776          if let Some(value) = checked_out_ligatures {
2777            state::checkin_value("MATH_LIGATURES", value);
2778          }
2779          return Ok(());
2780        }
2781      }
2782    }
2783    if let Some(value) = checked_out_ligatures {
2784      state::checkin_value("MATH_LIGATURES", value);
2785    }
2786    Ok(())
2787  }
2788
2789  /// Apply ligature operation to `node`, presumed the last insertion into it's parent(?)
2790  fn apply_math_ligature(&mut self, node: &mut Node, ligature: &Ligature) -> Result<bool> {
2791    if let Some((nmatched, newstring, attr)) = (ligature.matcher.as_ref().unwrap())(self, node)? {
2792      let mut boxes = VecDeque::new();
2793      boxes.push_front(self.get_node_box(node).unwrap());
2794      node.get_first_child().unwrap().set_content(&newstring)?;
2795      // `nmatched - 1` (usize) underflows to usize::MAX if a matcher returns a
2796      // zero-length match, spinning `get_prev_sibling().unwrap()` until it
2797      // panics. Saturate: a 0/1-length match removes no prior siblings.
2798      for _idx in 0..nmatched.saturating_sub(1) {
2799        // The matcher can OVER-report nmatched past the actual sibling count
2800        // (the mirror of the zero-length underflow above) — stop instead of
2801        // panicking on the unwrap (PR_READINESS must-fix 5).
2802        let Some(remove) = node.get_prev_sibling() else {
2803          Error!(
2804            "unexpected",
2805            "ligature",
2806            "Math ligature matched more siblings than exist; truncating the merge"
2807          );
2808          break;
2809        };
2810        if let Some(b) = self.get_node_box(&remove) {
2811          boxes.push_front(b);
2812        }
2813        self.remove_node(remove);
2814      }
2815      // This fragment replaces the node's box by the composite boxes it replaces
2816      // HOWEVER, this gets things out of sync because parent lists of boxes still
2817      // have the old ones.  Unless we could recursively replace all of them, we'd better skip
2818      // it(??)
2819      if boxes.len() > 1 {
2820        // TODO: Cloning boxes is BAD. What is a better model?
2821        let mut list = List::new(boxes.into_iter().collect::<Vec<_>>());
2822        list.mode = Some(TexMode::Math);
2823        self.set_node_box(node, list.into());
2824      }
2825      for (key, value_opt) in attr.sorted_each() {
2826        if let Some(value) = value_opt {
2827          node.set_attribute(key, value)?;
2828        } else {
2829          node.remove_attribute(key)?;
2830        }
2831      }
2832      Ok(true)
2833    } else {
2834      Ok(false)
2835    }
2836  }
2837
2838  /// Note that a box has been absorbed creating `node`;
2839  /// This does book keeping so that we can return the sequence of nodes
2840  /// that were added by absorbing material.
2841  pub fn record_constructed_node(&mut self, node: &Node) {
2842    // if ((defined $LaTeXML::RECORDING_CONSTRUCTION)    // If we're recording!
2843    let should_push = match self.constructed_nodes.last() {
2844      // and this node isn't already recorded
2845      None => true,
2846      Some(last_node) => last_node != node,
2847    };
2848    if should_push {
2849      self.constructed_nodes.push(node.clone());
2850    }
2851  }
2852
2853  pub fn filter_deletions(&self, nodes: Vec<Node>) -> Vec<Node> {
2854    // This test seems to successfully determine inclusion,
2855    // without requiring the (dangerous? & dubious?) unbindNode to be used.
2856    match self.document.get_root_element() {
2857      Some(root) => nodes
2858        .into_iter()
2859        .filter(|node| xml::is_descendant_or_self(node, &root))
2860        .collect(),
2861      _ => Vec::new(),
2862    }
2863  }
2864
2865  /// Given a list of nodes such as from ->absorb,
2866  /// filter out all the nodes that are children of other nodes in the list.
2867  pub fn filter_children(&self, mut nodes: Vec<Node>) -> Vec<Node> {
2868    if nodes.is_empty() {
2869      Vec::new()
2870    } else {
2871      let mut new = vec![nodes.remove(0)];
2872      for node in nodes {
2873        if new
2874          .iter()
2875          .all(|other| !xml::is_descendant_or_self(&node, other))
2876        {
2877          new.push(node)
2878        }
2879      }
2880      new
2881    }
2882  }
2883
2884  //**********************************************************************
2885  // Low level internal interface
2886
2887  /// Return a string indicating the path to the current insertion point in the document.
2888  /// if $levels is defined, show only that many levels
2889  pub fn get_insertion_context(&self, levels_opt: Option<usize>) -> Result<String> {
2890    let mut levels = match levels_opt {
2891      None => {
2892        // Default depth is based on verbosity
2893        if state::current_verbosity() <= 1 {
2894          Some(5)
2895        } else {
2896          None
2897        }
2898      },
2899      Some(t) => Some(t),
2900    };
2901    let mut node = self.node.clone();
2902    let node_type = node.get_type();
2903    if node_type != Some(NodeType::TextNode)
2904      && node_type != Some(NodeType::ElementNode)
2905      && node_type != Some(NodeType::DocumentNode)
2906    {
2907      let message = s!(
2908        "Insertion point is not an element, document or text: {:?}",
2909        self.document.node_to_string(&node)
2910      );
2911      Error!("internal", "context", message);
2912      return Ok(String::new());
2913    }
2914    // Build a context path like "<ltx:document><ltx:section><ltx:p>" by walking
2915    // ancestors and prepending each element's qname. Mirrors Perl's
2916    // `Stringify($node)` chain with a depth cap from `levels_opt`.
2917    //
2918    // Cap each qname at 80 chars. Pathological inputs (e.g. xy-pic
2919    // emitting unparsed `\fontdimen 17 \cmr10 at NNsp` strings that
2920    // become element names through a sequence of recovery errors)
2921    // can produce multi-MB qnames; walking 5+ ancestors and
2922    // `format!`-ing each yields a 3.25 GB allocation request that
2923    // OOM-kills the worker. Truncating preserves the diagnostic
2924    // signal ("first 80 chars + …") without the unbounded growth.
2925    // Witness papers: math0203082, math0402448 (R35.B, sandbox).
2926    const QNAME_CAP: usize = 80;
2927    let truncate_qname = |qname: &str| -> String {
2928      if qname.len() <= QNAME_CAP {
2929        qname.to_string()
2930      } else {
2931        let mut s: String = qname.chars().take(QNAME_CAP).collect();
2932        s.push('…');
2933        s
2934      }
2935    };
2936    let qn_for = |n: &Node| -> String {
2937      match n.get_type() {
2938        Some(NodeType::ElementNode) => {
2939          with_node_qname(n, |qname| format!("<{}>", truncate_qname(qname)))
2940        },
2941        Some(NodeType::TextNode) => "#text".to_string(),
2942        Some(NodeType::DocumentNode) => "#document".to_string(),
2943        _ => "?".to_string(),
2944      }
2945    };
2946    let mut path = qn_for(&node);
2947    while let Some(parent_node) = node.get_parent() {
2948      node = parent_node;
2949      if let Some(levels_val) = levels {
2950        levels = Some(levels_val - 1);
2951        if levels_val <= 1 {
2952          path = format!("...{path}");
2953          break;
2954        }
2955      }
2956      path = format!("{}{}", qn_for(&node), path);
2957    }
2958    Ok(path)
2959  }
2960
2961  /// Find the node where an element with qualified name `qname` can be inserted.
2962  /// This will move up the tree (closing auto-closable elements),
2963  /// or down (inserting auto-openable elements), as needed.
2964  pub fn find_insertion_point(
2965    &mut self,
2966    qname: &str,
2967    has_opened_opt: Option<SymStr>,
2968  ) -> Result<Node> {
2969    let qsym = arena::pin(qname);
2970    self.find_insertion_point_qsym(qsym, has_opened_opt)
2971  }
2972
2973  pub fn find_insertion_point_qsym(
2974    &mut self,
2975    qsym: SymStr,
2976    has_opened_opt: Option<SymStr>,
2977  ) -> Result<Node> {
2978    self.close_text_internal()?; // Close any current text node.
2979    let cur_qname = get_node_qname(&self.node);
2980    // If `qname` is allowed at the current point, we're done.
2981    if can_contain_qsym(cur_qname, qsym) {
2982      return Ok(self.node.clone());
2983    // Else, if we can create an intermediate node that accepts $qname, we'll do
2984    // that.
2985    } else if let Some(inter) = can_contain_indirect(cur_qname, qsym)
2986      && (inter != qsym)
2987      && (inter != cur_qname)
2988    {
2989      // TODO: can we avoid the clone here? there is a mutability conflict...
2990      let node_font = self.get_node_font(&self.node).clone();
2991      // TODO: avoid this clone?
2992      let inter_string = arena::to_string(inter);
2993      self.open_element(
2994        &inter_string,
2995        Some(string_map!("_autoopened" => "true")),
2996        Some(&node_font),
2997      )?;
2998      // And retry insertion (should work now).
2999      return self.find_insertion_point_qsym(qsym, Some(inter));
3000    }
3001    if let Some(has_opened) = has_opened_opt {
3002      // out of options if already inside an auto-open chain
3003      let message: String =
3004        arena::with2(has_opened, cur_qname, |has_opened_str, cur_qname_str| {
3005          Ok::<String, Error>(format!(
3006            "failed auto-open through <{}> at inadmissible <{}>. Currently in {}",
3007            has_opened_str,
3008            cur_qname_str,
3009            self.get_insertion_context(None)?
3010          ))
3011        })?;
3012      Error!("malformed", arena::to_string(qsym), message);
3013      Ok(self.node.clone()) // But we'll do it anyway, unless Error => Fatal.
3014    } else {
3015      // Now we're getting more desparate...
3016      // Check if we can auto close some nodes, and _then_ insert the `qname`.
3017      let mut node = self.node.clone();
3018      let mut close_to = None;
3019      while (node.get_type() != Some(NodeType::DocumentNode)) && can_auto_close(&node) {
3020        let parent_opt = node.get_parent();
3021        let parent_name = match parent_opt {
3022          None => pin!(""),
3023          Some(ref p) => get_node_qname(p),
3024        };
3025        if sym_can_contain_somehow(parent_name, qsym).is_some() {
3026          close_to = Some(node);
3027          break;
3028        }
3029        node = match parent_opt {
3030          Some(p) => p,
3031          None => break,
3032        };
3033      }
3034      if let Some(close_to_node) = close_to {
3035        self.close_node_internal(&close_to_node)?; // Close the auto closeable nodes.
3036        self.find_insertion_point_qsym(qsym, None) // Then retry, possibly w/auto open's
3037      } else {
3038        // Cascading-rejection suppression (2026-05-01): when a math leaf
3039        // element (`<ltx:XMTok>`) tries to insert into a text-mode
3040        // container (`<ltx:p>`/`<ltx:text>`), it's almost always a
3041        // cascade from a previously-rejected math wrapper (XMApp /
3042        // XMDual) — Perl emits the wrapper's rejection error but
3043        // doesn't continue to log per-child cascade errors. Mirror
3044        // that to drop the redundant noise. The Δ=2 witnesses are
3045        // hep-th0101146 (`$$ ... \end{equation}` mismatch) and
3046        // nlin0211024 (`${\mbox M}^{...}$$` inside `\begin{center}`).
3047        // We still return self.node.clone() so the caller proceeds
3048        // (the XMTok still gets inserted illegally, but the schema
3049        // validator will reject the whole math construct on
3050        // serialization anyway — the noise was purely diagnostic).
3051        let qsym_str = arena::to_string(qsym);
3052        let cur_str = arena::to_string(cur_qname);
3053        let is_math_leaf = qsym_str == "ltx:XMTok" || qsym_str == "ltx:XMArg";
3054        // `ltx:emph` added 2026-05-01 after math0010241 triage:
3055        // 13 XMTok-in-emph + 1 (Building line) cascade noise drops
3056        // Rust from 33 → 19, exact parity with Perl=19.
3057        let is_text_container =
3058          cur_str == "ltx:p" || cur_str == "ltx:text" || cur_str == "ltx:emph";
3059        if is_math_leaf && is_text_container {
3060          // Cascading rejection — skip the error log (Perl-faithful).
3061          return Ok(self.node.clone());
3062        }
3063        // Sectioning-unit-in-frontmatter leniency (Perl-faithful). A
3064        // `\paragraph{Keywords.}` / `\paragraph{MSC.}` inside an `abstract`
3065        // (a common author idiom) produces `<ltx:paragraph>` inside
3066        // `<ltx:abstract>`. The RNG `abstract_model = Block.model` excludes
3067        // sectioning units, but Perl's builder inserts it WITHOUT erroring
3068        // (Perl 0 / Rust 2 on 2311.06870) — its output literally nests
3069        // `<ltx:paragraph inlist="toc">` in `<ltx:abstract>`. Mirror that
3070        // build-leniency for the narrow sectioning-into-frontmatter case so
3071        // we don't out-strict Perl. Same `return self.node` "insert anyway"
3072        // mechanism as the math-leaf cascade above.
3073        let is_sectioning_unit = qsym_str == "ltx:paragraph" || qsym_str == "ltx:subparagraph";
3074        // Container is either a frontmatter block (abstract/acknowledgements,
3075        // Block.model — no sectioning units) OR another sectioning unit that
3076        // can't hold it (a 2nd `\paragraph` nests inside the 1st when the
3077        // enclosing abstract can't auto-close to a section level). Perl's
3078        // builder produces exactly this nested `<ltx:paragraph><ltx:paragraph>`
3079        // shape inside `<ltx:abstract>` without erroring (2311.06870: Perl 0).
3080        let is_lenient_container = cur_str == "ltx:abstract"
3081          || cur_str == "ltx:acknowledgements"
3082          || cur_str == "ltx:paragraph"
3083          || cur_str == "ltx:subparagraph";
3084        if is_sectioning_unit && is_lenient_container {
3085          return Ok(self.node.clone());
3086        }
3087        // Didn't find a legit place.
3088        // Perl Document.pm:1008-1010: "<qname> isn't allowed in <cur_qname>"
3089        // (a bare qname for #PCDATA), with "Currently in <insertion
3090        // context>" as a SEPARATE Error detail — NOT a Rust backtrace
3091        // (`disabled backtrace`) merged into the user-facing message.
3092        let message = arena::with2(cur_qname, qsym, |cur_qname_str, qname| {
3093          let qname_disp = if qname == "#PCDATA" {
3094            qname.to_string()
3095          } else {
3096            s!("<{}>", qname)
3097          };
3098          s!("{} isn't allowed in <{}>", qname_disp, cur_qname_str)
3099        });
3100        let context = s!("Currently in {}", self.get_insertion_context(None)?);
3101        Error!("malformed", arena::to_string(qsym), message, context);
3102
3103        // But we'll do it anyway, unless Error => Fatal.
3104        Ok(self.node.clone())
3105      }
3106    }
3107  }
3108
3109  fn get_insertion_candidates(&self, node: &Node) -> Vec<Node> {
3110    let mut nodes: Vec<Node> = Vec::new();
3111    // Check the current element FIRST, then build list of candidates.
3112    let first = if node.get_type() == Some(NodeType::TextNode) {
3113      Cow::Owned(node.get_parent().unwrap())
3114    } else {
3115      Cow::Borrowed(node)
3116    };
3117    let is_capture = first.get_name() == "_Capture_";
3118
3119    if first.get_type() != Some(NodeType::DocumentNode) && !is_capture {
3120      nodes.push(first.clone().into_owned());
3121    }
3122
3123    // Collect previous siblings, if node is a text node.
3124    let mut element_node_opt: Option<Cow<Node>> = if node.get_type() == Some(NodeType::TextNode) {
3125      let mut current_opt = Some(Cow::Borrowed(node));
3126      while let Some(current) = current_opt {
3127        current_opt = current.get_prev_sibling().map(Cow::Owned);
3128        if current.get_name() == "_Capture_" {
3129          nodes.extend(xml::element_nodes(&current));
3130        } else {
3131          nodes.push(current.into_owned());
3132        }
3133      }
3134      node.get_parent().map(Cow::Owned)
3135    } else {
3136      Some(Cow::Borrowed(node))
3137    };
3138    // Now collect (element) node & ancestors
3139    while let Some(element_node) = element_node_opt {
3140      element_node_opt = element_node.get_parent().map(Cow::Owned);
3141      let node_type = element_node.get_type();
3142      if node_type.is_none() || node_type == Some(NodeType::DocumentNode) {
3143        break;
3144      }
3145      if element_node.get_name() == "_Capture_" {
3146        nodes.extend(xml::element_nodes(&element_node));
3147      } else {
3148        nodes.push(element_node.into_owned());
3149      }
3150    }
3151    if is_capture {
3152      nodes.push(first.into_owned());
3153    }
3154
3155    nodes
3156  }
3157
3158  pub fn node_set_attribute(&mut self, key: &str, value: &str) -> Result<()> {
3159    if value.is_empty() {
3160      return Ok(()); // skip if empty
3161    }
3162    if key == "xml:id" {
3163      // If it's an ID attribute
3164      let recorded = self.record_id(value); // Do id book keeping
3165
3166      // TODO: Need to improve Namespace ergonomics, also in rust-libxml
3167      // let node_ns = self
3168      //   .document
3169      //   .get_root_element()
3170      //   .unwrap()
3171      //   .get_namespace_declarations()
3172      //   .into_iter()
3173      //   .find(|ns| ns.get_href() == XML_NS)
3174      //   .unwrap_or_else(|| {
3175      //     node
3176      //       .get_namespace_declarations()
3177      //       .into_iter()
3178      //       .find(|ns| ns.get_href() == XML_NS)
3179      //       .unwrap_or_else(|| {
3180      //         Namespace::new(
3181      //           "xml",
3182      //           &XML_NS.to_string().clone(),
3183      //           &mut self.document.get_root_element().unwrap(),
3184      //         ).unwrap_or_else(|_| {
3185      //           panic!(
3186      //             "Could not set NS for {:?}\n\n at \n\n {:?}",
3187      //             self.document.node_to_string(node),
3188      //             self.document.to_string(true)
3189      //           )
3190      //         })
3191      //       })
3192      //   });
3193
3194      self.node.set_attribute("xml:id", &recorded)?; // and bypass all ns stuff
3195    } else if !key.contains(':') {
3196      // No colon; no namespace (the common case!)
3197      // Ignore attributes not allowed by the model,
3198      // but accept "internal" attributes.
3199      let qname = get_node_qname(&self.node);
3200      if key.starts_with('_') || model::can_have_attribute(qname, arena::pin(key)) {
3201        self.node.set_attribute(key, value)?
3202      };
3203    } else {
3204      // Namespaced attributes: set directly for now.
3205      // TODO: proper namespace prefix resolution via model->decodeQName
3206      self.node.set_attribute(key, value)?;
3207      //     else {
3208      //       node.setAttributeNS($ns, "$prefix:$name" => $value); } }
3209      //   else {
3210      //     node.setAttribute($name => $value); } }
3211    } // redundant case...
3212    Ok(())
3213  }
3214  pub fn node_get_attribute(&mut self, name: &str) -> Option<String> {
3215    self.node.get_attribute(name)
3216  }
3217  //%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
3218  // Document surgery (?)
3219  // %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
3220  // The following carry out DOM modification but NOT relative to any current
3221  // insertion point (eg self.node), but rather relative to nodes specified
3222  // in the arguments.
3223
3224  /// Set any allowed attribute on a node, decoding the prefix, if any.
3225  /// Also records, and checks, any id attributes.
3226  /// \[xml:id and namespaced attributes are always allowed\]
3227  ///
3228  /// "Allowed" is the document model's call, so this is not a plain libxml
3229  /// write: an attribute the schema does not permit on `node` is dropped rather
3230  /// than emitted, which is what keeps a binding from producing a document that
3231  /// fails validation.
3232  pub fn set_attribute(&mut self, node: &mut Node, key: &str, value: &str) -> Result<()> {
3233    // Sanitize BEFORE the empty check: a value of only invalid chars (e.g. a
3234    // lone NUL) must degrade to the skip path, not reach libxml. Without this
3235    // an interior NUL panics in libxml's `CString::new(value).unwrap()`
3236    // (node.rs:639) and aborts the whole conversion — witness `$a^^@b$`-class
3237    // stray NULs (BibTeX `\"u`-mangling) reaching the `tex=` reversion
3238    // attribute now that NUL's catcode is 12/OTHER like Perl. The text sinks
3239    // (`open_text_internal`/`open_math_text_internal`) already stripped NUL;
3240    // this attribute sink did not. PR #249 review P0-1.
3241    let value_sanitized = xml_sanitize(value);
3242    let value: &str = &value_sanitized;
3243    if value.is_empty() {
3244      return Ok(()); // skip if empty
3245    }
3246    // Normalise the key first, then run Perl's plain `setAttribute`
3247    // (`Core/Document.pm:1370-1386`): schema-check, then the LITERAL `xml:id`
3248    // test. The normalisation is needed because this port spells `xml:id` as a
3249    // bare `id` throughout its property/attribute maps -- the math parser,
3250    // `base_xmath`, alignment rows and `RefStepID`'s `#id` all do it, where
3251    // Perl's constructors always write `xml:id` out in full -- so a bare `id`
3252    // arriving here normally MEANS `xml:id`. The exception is that `id` is ALSO
3253    // a real attribute in the model: `LaTeXML-bib.rnc:335,353` declare
3254    // `attribute id { text }?` on `ltx:bib-identifier` and `ltx:bib-review`,
3255    // carrying the ISSN/DOI/MR identifier itself. Letting the alias apply
3256    // unconditionally rewrote those into invalid-NCName ids
3257    // (`xml:id="0010-3640,1097-0312"`) -- witness arXiv 2508.17585.
3258    let key = if key == "id" && !model::can_have_attribute(get_node_qname(node), arena::pin("id")) {
3259      "xml:id"
3260    } else {
3261      key
3262    };
3263    // Accept internal attributes (starting with _), namespaced (containing :), and model-allowed.
3264    if !key.starts_with('_') && !key.contains(':') {
3265      let qname = get_node_qname(node);
3266      if !model::can_have_attribute(qname, arena::pin(key)) {
3267        return Ok(()); // silently skip attributes not allowed by schema
3268      }
3269    }
3270    if key == "xml:id" {
3271      // record_id_with_node detects duplicates and returns a
3272      // deduplicated id when a DIFFERENT node already claims the
3273      // same id. Previously we discarded that return value and
3274      // wrote the original `value`, which meant libxml2 saw two
3275      // nodes with the same xml:id. The post-processing scan's
3276      // libxml2 idHash lookups then ran O(n²) on any document
3277      // with enough duplicates (see 1106.1389 / KNOWN_PERL_ERRORS
3278      // #13: 14 duplicate-id sites from \addtocounter{equation}{-1}
3279      // + \subequations inside a \newtheorem[equation] theorem).
3280      let deduped = self.record_id_with_node(value, node);
3281      node.set_attribute("xml:id", &deduped)?;
3282    } else if !key.contains(':') {
3283      // No colon; no namespace (the common case!)
3284      // Note: Full model validation (can_have_attribute) is done in node_set_attribute.
3285      // Here we just set the attribute, since the caller is responsible for filtering.
3286      node.set_attribute(key, value)?;
3287    } else {
3288      // Namespaced attribute (`prefix:local`). Mirror Perl
3289      // `Core/Document.pm::setAttribute`, whose `getDocumentNamespacePrefix($ns, 1)`
3290      // *promotes* the prefix's namespace to a document namespace on first use.
3291      // That lets finalize's `apply_document_namespace_declarations` declare
3292      // `xmlns:prefix` on the root, so the prefixed attribute resolves into its
3293      // namespace on serialization and the post XSLT can copy it (e.g.
3294      // `data:sourcepos` → `data-sourcepos`). Without the promotion a code-only
3295      // namespace (like `data`, used by `--source-map`) is emitted unbound and
3296      // dropped. General over any registered prefix — implements the decodeQName
3297      // TODO. (`aria`/schema namespaces are already document namespaces, so the
3298      // re-registration is an idempotent no-op for them.)
3299      if let Ok((Some(ns_uri), _local)) = model::decode_qname(key) {
3300        let prefix = key.split(':').next().unwrap_or("");
3301        model::register_document_namespace(prefix, Some(&ns_uri));
3302      }
3303      node.set_attribute(key, value)?;
3304    }
3305    // ... TODO: continue (see Perl)
3306    Ok(())
3307  }
3308
3309  pub fn add_ss_values(&mut self, node: &mut Node, key: &str, values_str: &str) -> Result<()> {
3310    // $values = $values->toAttribute if ref $values;
3311    if !values_str.is_empty() {
3312      // Skip if `empty'; but 0 is OK!
3313      let mut values: Vec<&str> = values_str.split_whitespace().collect();
3314      if let Some(oldvalues) = node.get_attribute(key) {
3315        // previous values?
3316        let mut old: Vec<&str> = oldvalues.split_whitespace().collect();
3317        for new in values {
3318          if old.iter().all(|v| *v != new) {
3319            old.push(new);
3320          }
3321        }
3322        old.sort_unstable();
3323        self.set_attribute(node, key, &old.join(" "))?;
3324      } else {
3325        values.sort_unstable();
3326        self.set_attribute(node, key, &values.join(" "))?;
3327      }
3328    }
3329    Ok(())
3330  }
3331
3332  /// Add one or more CSS classes to a node, without disturbing the ones it
3333  /// already carries.
3334  ///
3335  /// `class` is treated as the space-separated set it is
3336  /// ([`add_ss_values`](Self::add_ss_values)), so several bindings can each
3337  /// contribute a class to the same element — which is why this exists rather
3338  /// than a plain `setAttribute("class", …)`, and what
3339  /// [`remove_ss_values`](Self::remove_ss_values) undoes.
3340  pub fn add_class(&mut self, node: &mut Node, class: &str) -> Result<()> {
3341    self.add_ss_values(node, "class", class)
3342  }
3343
3344  /// Remove space-separated values from an attribute.
3345  /// Perl: sub removeSSValues (Document.pm lines 1423-1437)
3346  pub fn remove_ss_values(&mut self, node: &mut Node, key: &str, values: &str) {
3347    let to_remove: Vec<&str> = values.split_whitespace().collect();
3348    if to_remove.is_empty() {
3349      return;
3350    }
3351    if let Some(current) = node.get_attribute(key) {
3352      let updated: Vec<&str> = current
3353        .split_whitespace()
3354        .filter(|v| !to_remove.contains(v))
3355        .collect();
3356      if updated.is_empty() {
3357        let _ = node.remove_attribute(key);
3358      } else {
3359        let mut sorted = updated;
3360        sorted.sort_unstable();
3361        node
3362          .set_attribute(key, &sorted.join(" "))
3363          .unwrap_or_default();
3364      }
3365    }
3366  }
3367
3368  /// Remove CSS class from element.
3369  /// Perl: sub removeClass (Document.pm lines 1439-1442)
3370  pub fn remove_class(&mut self, node: &mut Node, class: &str) {
3371    self.remove_ss_values(node, "class", class);
3372  }
3373
3374  /// Float to a node that can accept the given attribute.
3375  /// Returns the previous node so it can be restored after setting the attribute.
3376  /// Perl: sub floatToAttribute (Document.pm lines 1080-1092)
3377  pub fn float_to_attribute(&mut self, key: &str) -> Option<Node> {
3378    let candidates = self.get_insertion_candidates(&self.node);
3379    for candidate in candidates {
3380      let qname_sym = get_node_qname(&candidate);
3381      if sym_can_have_attribute(qname_sym, arena::pin(key)) {
3382        let savenode = self.node.clone();
3383        self.set_node(&candidate);
3384        return Some(savenode);
3385      }
3386    }
3387    Warn!(
3388      "malformed",
3389      key,
3390      s!("No open node can get attribute '{}'", key)
3391    );
3392    None
3393  }
3394
3395  /// Check if a node is currently open (i.e., is or contains the current node).
3396  /// Perl: sub isOpen (Document.pm lines 1998-2006)
3397  pub fn is_open(&self, node: &Node) -> bool {
3398    if *node == self.node {
3399      return true;
3400    }
3401    for child in node.get_child_nodes() {
3402      if self.is_open(&child) {
3403        return true;
3404      }
3405    }
3406    false
3407  }
3408
3409  //**********************************************************************
3410  // Association of nodes and ids (xml:id)
3411
3412  /// Records the association of the current Document `node` with the `id`,
3413  /// which should be the `xml:id` attribute of the `node`.
3414  /// Usually this association will be maintained by the methods
3415  /// that create nodes or set attributes.
3416  fn record_id(&mut self, id: &str) -> String {
3417    let needs_modify = if let Some(prev) = self.idstore.get(id) {
3418      // Whoops! Already assigned!!!
3419      // Can we recover?
3420      self.node != *prev
3421    } else {
3422      false
3423    };
3424    let final_id = if needs_modify {
3425      let badid = id.to_string();
3426      let new_id = self.modify_id(badid);
3427      Info!(
3428        "malformed",
3429        "id",
3430        s!("Duplicated attribute xml:id. Using id='{}'", new_id)
3431      );
3432      new_id
3433    } else {
3434      id.to_string()
3435    };
3436    self.idstore.insert(final_id.clone(), self.node.clone());
3437    final_id
3438  }
3439
3440  /// Records the association of the given `node` with the `id`,
3441  /// which should be the `xml:id` attribute of the `node`.
3442  /// Usually this association will be maintained by the methods
3443  /// that create nodes or set attributes.
3444  fn record_id_with_node(&mut self, id: &str, node: &Node) -> String {
3445    let prev_opt = if let Some(prev) = self.idstore.get(id) {
3446      // Whoops! Already assigned!!!
3447      // Can we recover? Only conflict if a DIFFERENT node already has this id.
3448      if node != prev {
3449        Some(prev.clone())
3450      } else {
3451        None
3452      }
3453    } else {
3454      None
3455    };
3456    // The spilled half: an id that left the idstore with a spilled fragment
3457    // is just as taken (its node is on disk, not in the tree).
3458    let spilled_collision = prev_opt.is_none() && self.spilled_ids.contains(id);
3459    let final_id = if spilled_collision {
3460      let new_id = self.modify_id(id.to_owned());
3461      Debug!(
3462        "malformed",
3463        "id",
3464        s!(
3465          "Duplicated attribute xml:id. Using id='{}' on <{}> id='{}' already set on a disk-staged fragment",
3466          new_id,
3467          arena::to_string(get_node_qname(node)),
3468          id
3469        )
3470      );
3471      new_id
3472    } else if let Some(prev) = prev_opt {
3473      let badid = id;
3474      let new_id = self.modify_id(id.to_owned());
3475      // Concise node descriptions, mirroring Perl `Stringify($node)`
3476      // (Common/Object.pm L40-49: `<tag attrs…>` with no child
3477      // serialization). Rust previously dumped the FULL node via
3478      // `node_to_string`, which (a) diverged from Perl's concise form and
3479      // (b) spilled child TEXT into the log — e.g. a figure caption
3480      // beginning "Error bars are the standard deviations…" then appears as
3481      // a line starting "Error" and is mis-counted as an error by
3482      // text-grep error sweeps (false positive on 2009.01426, which has
3483      // ZERO real errors). Use just the qname (+ the relevant id), which is
3484      // what an id-dedup diagnostic actually needs.
3485      let message = s!(
3486        "Duplicated attribute xml:id. Using id='{}' on <{}> id='{}' already set on <{}>",
3487        new_id,
3488        arena::to_string(get_node_qname(node)),
3489        badid,
3490        arena::to_string(get_node_qname(&prev))
3491      );
3492      // Perl-faithful (Document.pm L1454): Info-level. The id-counter
3493      // collision is the dedup-recovery path (`modify_id` appends
3494      // suffix), not silent corruption. The earlier Error-level
3495      // promotion was motivated by 1410.8171 (Sárkány PRA), but root-
3496      // cause investigation showed the empty-S3+ rendering there was
3497      // the siunitx ExplodeText! tokenization bug (fixed by
3498      // fc2aae7266), not the dedup recovery. After that fix, the
3499      // residual dedup events on the canvas are SHARED with Perl —
3500      // the in-tree test fixture tests/math/declare.xml itself bakes
3501      // in the `xml:id="S1.Ex1.m1.2a"` dedup result, confirming
3502      // Perl produces the same behavior. Downstream broken XMRefs
3503      // (post-dedup) emit Warning:expected:node "No node found with
3504      // id=…" which already surfaces the consequence at the appro-
3505      // priate severity. Math-parser hygiene fix (preventing the
3506      // collision in the first place) is tracked separately.
3507      Debug!("malformed", "id", message);
3508      new_id
3509    } else {
3510      id.to_string()
3511    };
3512    self.idstore.insert(final_id.clone(), node.clone());
3513    final_id
3514  }
3515
3516  pub fn unrecord_id(&mut self, id: &str) { self.idstore.remove(id); }
3517
3518  /// Guardian-safe unlink: walk `node`'s subtree invalidating every `xml:id`
3519  /// idstore entry, then detach it from its parent. Use this in preference
3520  /// to a raw `node.unlink()` anywhere the node *might* carry an `xml:id`
3521  /// (subtree reshuffles in math-parser, post-processing cleanup, etc.),
3522  /// to prevent the dangling-Node class of bug that produced the 1605.08055
3523  /// Finalizing-phase SIGSEGV (see SYNC_STATUS.md D3b).
3524  ///
3525  /// This is the unlink-only half of `remove_node` — it does **not** adjust
3526  /// `self.node` / the insertion point, which is correct for callers that
3527  /// intend to re-parent the unlinked subtree elsewhere (the common case).
3528  /// Callers that want the insertion-point bookkeeping should use
3529  /// `remove_node` instead.
3530  pub fn safe_unlink(&mut self, mut node: Node) {
3531    if node.get_type() == Some(NodeType::ElementNode) {
3532      if let Some(id) = node.get_attribute_ns("id", XML_NS) {
3533        self.unrecord_id(&id);
3534      }
3535      for child in node.get_child_nodes() {
3536        self.remove_node_aux(child);
3537      }
3538    }
3539    node.unlink();
3540  }
3541
3542  /// These are used to record or unrecord, in bulk, all the ids within a node (tree).
3543  ///
3544  /// When `record_id_with_node` detects a duplicate it renames the id (e.g.
3545  /// `X.1.mf` → `X.1.mfa`). Any sibling `<ltx:XMRef idref="X.1.mf"/>` in the
3546  /// same subtree would otherwise become a dangling reference for the post-
3547  /// processor. After re-recording IDs, sweep the subtree once and update any
3548  /// XMRef whose `idref` matches an entry in the rename map.
3549  ///
3550  /// Perl `Core::Document::recordNodeIDs` (Document.pm L1466-1472) has the
3551  /// same latent bug — recording renames but XMRefs aren't touched. The bug
3552  /// surfaces in our port because the math-parser path
3553  /// (`parser.rs::install_replacements`-style `unrecord+record` round-trips)
3554  /// is denser than Perl's; both end up needing this remap to keep XMRef
3555  /// chains intact. Intentional surpass-Perl divergence; tracked in
3556  /// SYNC_STATUS Task #10.
3557  pub fn record_node_ids(&mut self, node: &Node) -> Result<()> {
3558    use rustc_hash::FxHashMap;
3559    let mut rename: FxHashMap<String, String> = FxHashMap::default();
3560    for mut idnode in self.findnodes("descendant-or-self::*[@xml:id]", Some(node)) {
3561      if let Some(id) = idnode.get_attribute_ns("id", XML_NS) {
3562        let newid = self.record_id_with_node(&id, &idnode);
3563        if newid != id {
3564          idnode.set_attribute("xml:id", &newid)?;
3565          rename.insert(id, newid);
3566        }
3567      }
3568    }
3569    if !rename.is_empty() {
3570      for mut xmref in self.findnodes("descendant-or-self::*[@idref]", Some(node)) {
3571        if let Some(idref) = xmref.get_attribute("idref")
3572          && let Some(new) = rename.get(&idref)
3573        {
3574          xmref.set_attribute("idref", new)?;
3575        }
3576      }
3577    }
3578    Ok(())
3579  }
3580
3581  pub fn unrecord_node_ids(&mut self, node: &Node) {
3582    for idnode in self.findnodes("descendant-or-self::*[@xml:id]", Some(node)) {
3583      if let Some(id) = idnode.get_attribute_ns("id", XML_NS) {
3584        self.unrecord_id(&id);
3585      }
3586    }
3587  }
3588
3589  //**********************************************************************
3590  // Streaming (fragmented) conversion: spill closed subtrees to disk.
3591
3592  /// Spill closed subtrees to the segment store, freeing their DOM memory
3593  /// (streaming pass 1).
3594  ///
3595  /// Walks the SPINE — root element down to the current insertion element —
3596  /// and, at every level, spills the runs of closed element children that
3597  /// precede the open (spine) child: serialize in the pre-finalize form with
3598  /// the exact `(depth, noindent)` the eager serializer would use, write to
3599  /// `store`, record every spilled `xml:id`/label in `index`, purge the
3600  /// pointer-keyed registries (`idstore` via `unrecord_id`, `node_boxes` by
3601  /// key — freed nodes' pointers can be REUSED, so a stale entry is not just
3602  /// dangling but can mis-associate a box with a future node), replace the
3603  /// run with one `<_spilled_ ref="N"/>` placeholder, and unlink+free the
3604  /// nodes. Serialization at assembly time splices the processed segment
3605  /// where the placeholder sits, so document order, nesting and
3606  /// spine-attribute finality are preserved by construction.
3607  ///
3608  /// Two deliberate exclusions:
3609  /// * ROOT-level children spill only when sectional (`ROOT_SPILLABLE`):
3610  ///   the frontmatter fallback and `maybe_promote_leading_title` operate on
3611  ///   the leading non-sectional root children at end-of-build.
3612  /// * Children of the insertion element itself never spill: in-flight
3613  ///   construction state may still reference recently built nodes.
3614  ///
3615  /// Returns the number of runs spilled.
3616  pub fn spill_closed_subtrees(&mut self, index: &mut crate::sxml::FragmentIndex) -> Result<usize> {
3617    debug_assert!(
3618      self.spill_store.is_some(),
3619      "spill_closed_subtrees needs the store attached (set_spill_store)"
3620    );
3621    let Some(root) = self.document.get_root_element() else {
3622      return Ok(0);
3623    };
3624    // The spine, root-first. The insertion point may be a text node; start
3625    // from its nearest element.
3626    let mut spine: Vec<Node> = Vec::new();
3627    let mut cursor = if self.node.get_type() == Some(NodeType::ElementNode) {
3628      Some(self.node.clone())
3629    } else {
3630      self.node.get_parent()
3631    };
3632    while let Some(n) = cursor {
3633      if n.get_type() != Some(NodeType::ElementNode) {
3634        break;
3635      }
3636      cursor = n.get_parent();
3637      spine.push(n);
3638    }
3639    spine.reverse();
3640    if spine.is_empty() && self.node.get_type() == Some(NodeType::DocumentNode) {
3641      // The ROOT itself has closed (`\end{document}` absorbed): everything
3642      // under it is a closed subtree now, so the spine is just the root and
3643      // every eligible child may spill. Without this case the FINAL spill of
3644      // a streaming conversion silently no-ops — the witness kept 27,400
3645      // formulae live into the spine tail this way and died there.
3646      spine.push(root.clone());
3647    }
3648    if spine.first() != Some(&root) {
3649      // The insertion point is outside the tree under the root: nothing is
3650      // safely classifiable as closed.
3651      return Ok(0);
3652    }
3653    let namespaces: Vec<(String, String)> = root
3654      .get_namespace_declarations()
3655      .iter()
3656      .map(|ns| (ns.get_prefix(), ns.get_href()))
3657      .collect();
3658
3659    let mut runs_spilled = 0usize;
3660    // Every spine level INCLUDING the insertion element itself: at a legal
3661    // yield seam the insertion point routinely IS the root (between
3662    // top-level constructs), where a levels-above-only walk sees an empty
3663    // spine range and spills NOTHING — the witness leaked two thirds of its
3664    // chapters into the spine tail exactly this way. The insertion element's
3665    // children are complete, closed subtrees at a seam (the constructed-node
3666    // stacks expire between absorbs); its level simply has no barrier child.
3667    for level in 0..spine.len() {
3668      let parent = spine[level].clone();
3669      let barrier = spine.get(level + 1).cloned();
3670      // The ambient SECTION for scope-gated processing (`\lxDeclare`):
3671      // nearest section at or above this spill parent, mirroring the
3672      // ancestor walk `apply_lx_declarations` performs.
3673      let section_id = spine[..=level]
3674        .iter()
3675        .rev()
3676        .find(|n| n.get_name() == "section")
3677        .and_then(|n| n.get_attribute_ns("id", XML_NS));
3678      // The serializer's recursion contract for children of `parent`
3679      // (`serialize_into`: depth+1, parent's schema-driven noindent).
3680      let noindent = {
3681        let parent_qname = get_node_qname(&parent);
3682        model::can_contain_sym(parent_qname, pin!("#PCDATA"))
3683      };
3684      // Do NOT spill the sole `ltx:text` child of an open `ltx:text` frame: that
3685      // is exactly the `auto_collapse_children` pattern (a redundant font/frame
3686      // wrapper merged into its parent at close). Spilling the child first leaves
3687      // the still-open frame with a `<_spilled_/>` placeholder, so the close-time
3688      // collapse sees a non-`ltx:text` child and skips the merge the EAGER path
3689      // performs — the streamed output then nests where eager collapses, breaking
3690      // the byte-identity invariant (witness: a `breaklines=true` framed
3691      // `Verbatim` whose parbox layer crosses a fragment seam, issue #702). The
3692      // protected node is a small wrapper; keeping it resident until the frame
3693      // closes costs negligible memory. Conservative on purpose: it also spares a
3694      // few wrappers `auto_collapse` would NOT merge (`_force_font` /
3695      // non-mergeable attr), which stay nested in BOTH paths anyway.
3696      let parent_is_collapse_frame = get_node_qname(&parent) == pin!("ltx:text") && {
3697        let elem_kids: Vec<Node> = parent
3698          .get_child_nodes()
3699          .into_iter()
3700          .filter(|c| {
3701            c.get_type() == Some(NodeType::ElementNode) && c.get_name() != SPILL_PLACEHOLDER
3702          })
3703          .collect();
3704        elem_kids.len() == 1 && get_node_qname(&elem_kids[0]) == pin!("ltx:text")
3705      };
3706      let mut run: Vec<Node> = Vec::new();
3707      for child in parent.get_child_nodes() {
3708        if Some(&child) == barrier.as_ref() {
3709          break;
3710        }
3711        let eligible = child.get_type() == Some(NodeType::ElementNode)
3712          && child.get_name() != SPILL_PLACEHOLDER
3713          && !parent_is_collapse_frame
3714          && (level > 0 || ROOT_SPILLABLE.contains(&child.get_name().as_str()))
3715          // A bibliography pins its subtree in RAM: a LATER `\bibstyle`
3716          // writes attributes onto it through a doc-wide search (sweep
3717          // witness tests/structure/bibsect.tex). Rare and small.
3718          && self
3719            .findnodes("descendant-or-self::ltx:bibliography", Some(&child))
3720            .is_empty();
3721        if eligible {
3722          run.push(child);
3723        } else if !run.is_empty() {
3724          // A non-eligible node interrupts the run: flush what precedes it so
3725          // each placeholder replaces exactly the contiguous nodes it stands
3726          // for, and document order is preserved around the interloper.
3727          runs_spilled += self.spill_run(
3728            &mut run,
3729            level + 1,
3730            noindent,
3731            &namespaces,
3732            &section_id,
3733            index,
3734          )?;
3735        } else {
3736          run.clear();
3737        }
3738      }
3739      if !run.is_empty() {
3740        runs_spilled += self.spill_run(
3741          &mut run,
3742          level + 1,
3743          noindent,
3744          &namespaces,
3745          &section_id,
3746          index,
3747        )?;
3748      }
3749    }
3750    Ok(runs_spilled)
3751  }
3752
3753  /// Spill one contiguous run of closed sibling elements as a single segment.
3754  /// Consumes (drains) `run`; on success the nodes are unlinked and freed.
3755  /// Serialized-bytes ceiling per SEGMENT. A spill run is chunked so no
3756  /// segment exceeds it (approximately — a single oversized subtree still
3757  /// becomes one segment): pass 2 re-materializes a segment WHOLE, so the
3758  /// segment size bounds pass-2 peak memory the way the yield budget bounds
3759  /// pass 1. The witness's first RSS-triggered yield once spilled half the
3760  /// document as ONE 512 MB segment, and pass 2 died re-parsing it.
3761  fn segment_chunk_bytes() -> usize {
3762    // Calibration override (`LATEXML_SEGMENT_CHUNK_MIB`), deliberately env-only
3763    // and NOT a CLI flag — the same reasoning as `LATEXML_SPILL_AT_MIB`, and
3764    // for the same reason: MAX below is an ARGUED constant, not a measured one.
3765    //
3766    // Its stated basis is a TIME claim ("the point past which a single
3767    // segment's re-parse dominates pass 2"), but the pass-2 tail is linear in
3768    // fragment size — `prune_dangling_split_xmrefs`, `prune_xmduals`,
3769    // `mark_xmnode_visibility` and `cleanup_unreferenced_xmtok_ids` are each
3770    // one `findnodes` plus an O(1)-per-node loop, `finalize_subtree` is a
3771    // recursive walk, and the rewrite rule set is fixed. Larger segments
3772    // should also SUIT the allocator, which is the largest single profile
3773    // category (~16.5%): fewer, bigger alloc/free cycles.
3774    //
3775    // Note what the derivation below wants: at --max-memory 48000 it computes
3776    // watermark/72 = 166 MB and MAX then clamps it to 32 MB. This knob exists
3777    // so that ceiling can be swept rather than argued.
3778    if let Some(mib) = std::env::var("LATEXML_SEGMENT_CHUNK_MIB")
3779      .ok()
3780      .and_then(|v| v.parse::<usize>().ok())
3781      .filter(|mib| *mib > 0)
3782    {
3783      return mib.saturating_mul(1024 * 1024);
3784    }
3785    /// Validated on the 131 MB witness. The accompanying claim that a single
3786    /// segment's re-parse dominates pass 2 past this point is NOT substantiated
3787    /// by the pass-2 tail, which is linear — see the override above.
3788    const MAX: usize = 32 * 1024 * 1024;
3789    /// Below this, per-segment overhead (parse, index merge, reserialize)
3790    /// outweighs the memory saved.
3791    const MIN: usize = 4 * 1024 * 1024;
3792    /// Serialized bytes → live DOM, measured: 18.6 MB of core XML
3793    /// materialized as ~1,346 MB of libxml2 DOM on a witness slice.
3794    const DOM_EXPANSION: u64 = 72;
3795    match crate::stomach::spill_watermark_bytes() {
3796      // Pass 2 re-materializes ONE segment whole, so a chunk must fit the
3797      // same watermark pass 1 respects — otherwise the phase that exists to
3798      // bound memory becomes the phase that blows it on a small machine.
3799      Some(watermark) => ((watermark / DOM_EXPANSION) as usize).clamp(MIN, MAX),
3800      None => MAX,
3801    }
3802  }
3803
3804  fn spill_run(
3805    &mut self,
3806    run: &mut Vec<Node>,
3807    depth: usize,
3808    noindent: bool,
3809    namespaces: &[(String, String)],
3810    section_id: &Option<String>,
3811    index: &mut crate::sxml::FragmentIndex,
3812  ) -> Result<usize> {
3813    use crate::sxml::SegmentMeta;
3814    // An element whose children are exclusively EMPTY text nodes serializes
3815    // as `<p></p>` — indistinguishable, after a parse round-trip, from a
3816    // childless `<p/>`. Mark such elements before serializing so pass 2 can
3817    // restore the empty text child and keep the round-trip exact (sweep
3818    // witness tests/fonts/abxtest.tex).
3819    fn mark_empty_text_elements(node: &Node) {
3820      if node.get_type() != Some(NodeType::ElementNode) {
3821        return;
3822      }
3823      let children = node.get_child_nodes();
3824      if !children.is_empty()
3825        && children
3826          .iter()
3827          .all(|c| c.get_type() == Some(NodeType::TextNode) && c.get_content().is_empty())
3828      {
3829        let mut n = node.clone();
3830        let _ = n.set_attribute("_lx_empty_text", "1");
3831      }
3832      for child in children {
3833        mark_empty_text_elements(&child);
3834      }
3835    }
3836    for node in run.iter() {
3837      mark_empty_text_elements(node);
3838    }
3839    // Stamp the DIGESTED BOX's math-font verdict on XMArg elements before
3840    // their boxes are purged: the FLOATSUPERSCRIPT rewrite (tex_math.rs)
3841    // decides `<text font="italic">` wrapping from the box font's family,
3842    // and a fragment re-materialized in pass 2 has no boxes. An attr carries
3843    // the EXACT verdict (an ancestor-`_font` approximation mis-wrapped
3844    // text-mode superscripts — sweep witness tests/math/niceunits.tex).
3845    for node in run.iter() {
3846      for mut xmarg in self.findnodes("descendant-or-self::ltx:XMArg", Some(node)) {
3847        if let Some(tbox) = self.get_node_box(&xmarg)
3848          && let Ok(Some(font)) = tbox.get_font()
3849          && font
3850            .get_family()
3851            .map(|f| f.as_ref() == "math")
3852            .unwrap_or(false)
3853        {
3854          let _ = xmarg.set_attribute("_boxfont_math", "1");
3855        }
3856      }
3857    }
3858
3859    // Chunk the run so each segment stays under `segment_chunk_bytes()` of
3860    // serialized text; every chunk gets its own segment + placeholder, so
3861    // document order is preserved chunk-by-chunk.
3862    let chunk_ceiling = Self::segment_chunk_bytes();
3863    let mut runs_spilled = 0usize;
3864    let mut chunk: Vec<Node> = Vec::new();
3865    let mut xml = String::new();
3866    let all: Vec<Node> = std::mem::take(run);
3867    let total = all.len();
3868    for (i, node) in all.into_iter().enumerate() {
3869      // A spilled subtree can CONTAIN placeholders from earlier, deeper
3870      // spills (a closing section swallows its paragraphs' placeholders).
3871      // They serialize LITERALLY (`literal_placeholders` is set for all of
3872      // pass 1) and the final assembly's splice resolves them recursively —
3873      // inlining them here instead once rebuilt an 841 MB and a 1.85 GB
3874      // segment out of chapter shells on the 131 MB witness, and pass 2
3875      // died re-materializing them.
3876      debug_assert!(
3877        self.literal_placeholders,
3878        "spill_run must serialize placeholders literally (set for all of pass 1)"
3879      );
3880      self.serialize_into(&mut xml, &node, depth, noindent, false);
3881      chunk.push(node);
3882      let last = i + 1 == total;
3883      if xml.len() >= chunk_ceiling || last {
3884        let meta = SegmentMeta {
3885          depth,
3886          noindent,
3887          font: None,
3888          namespaces: namespaces.to_vec(),
3889          section_id: section_id.clone(),
3890          parent: chunk
3891            .first()
3892            .and_then(|n| n.get_parent())
3893            .map(|p| arena::to_string(get_node_qname(&p))),
3894          ancestors: {
3895            // Every ancestor xml:id: a scope rooted at one of these covers
3896            // the whole fragment (see SegmentMeta::ancestors).
3897            let mut ids = Vec::new();
3898            let mut cur = chunk.first().and_then(|n| n.get_parent());
3899            while let Some(n) = cur {
3900              // Both attribute forms — constructed ancestors carry the plain
3901              // one (see `node_xml_id_any_form`); a namespace-only read left
3902              // these lists near-empty, so label/id-scoped rules could not
3903              // cover their fragments.
3904              if let Some(id) = Self::node_xml_id_any_form(&n) {
3905                ids.push(id);
3906              }
3907              cur = n.get_parent();
3908            }
3909            ids
3910          },
3911        };
3912        let store = self
3913          .spill_store
3914          .as_mut()
3915          .expect("checked at spill_closed_subtrees entry");
3916        let seg = store.write_segment(&xml, meta)?;
3917        xml.clear();
3918        // Index + purge while the nodes are still alive, then placeholder +
3919        // free.
3920        for node in chunk.iter() {
3921          self.index_and_purge_spilled(node, seg, index);
3922        }
3923        let spill_error = |details: String| Error {
3924          target:   ErrorTarget::Document,
3925          category: ErrorCategory::Libxml,
3926          message:  s!("segment staging: {}", details),
3927        };
3928        let mut placeholder = Node::new(SPILL_PLACEHOLDER, None, &self.document)
3929          .map_err(|()| spill_error(s!("cannot create the placeholder node")))?;
3930        placeholder
3931          .set_attribute("ref", &seg.to_string())
3932          .map_err(|e| spill_error(s!("cannot mark the placeholder: {e}")))?;
3933        // Record the LAST spilled sibling's qname: build-time hooks that
3934        // consult a previous element sibling (prune_empty_para's
3935        // was-I-first test — sweep witness tests/alignment/colortbls.tex)
3936        // would otherwise see the placeholder where eager sees that node.
3937        // Never serialized — assembly splices the segment text instead.
3938        if let Some(last) = chunk.last() {
3939          placeholder
3940            .set_attribute("last", &arena::to_string(get_node_qname(last)))
3941            .map_err(|e| spill_error(s!("cannot mark the placeholder: {e}")))?;
3942        }
3943        chunk
3944          .first_mut()
3945          .expect("a flushed chunk is non-empty")
3946          .add_prev_sibling(&mut placeholder)
3947          .map_err(|e| spill_error(s!("cannot insert the placeholder: {e}")))?;
3948        for node in chunk.drain(..) {
3949          // Actually FREE the spilled subtree (unlink alone leaks — see
3950          // Node::free_subtree). Its ids were unrecorded by
3951          // index_and_purge_spilled above; discard_subtree re-purges
3952          // node_boxes (cheap) and reclaims the C memory, which is the whole
3953          // point of the spill.
3954          self.discard_subtree(node);
3955        }
3956        SPILLED_SEGMENTS.set(SPILLED_SEGMENTS.get() + 1);
3957        runs_spilled += 1;
3958      }
3959    }
3960    Ok(runs_spilled)
3961  }
3962
3963  /// A node's `xml:id`, namespace-aware with a defensive plain-name fallback.
3964  ///
3965  /// MECHANISM CORRECTION (2026-08-04, empirically probed against libxml
3966  /// 0.3.21): `set_attribute("xml:id", …)` DOES namespace the attribute
3967  /// (`xmlSetProp` resolves the predefined `xml` prefix), so builder-
3968  /// CONSTRUCTED nodes and PARSED nodes both store the namespaced form and
3969  /// `get_attribute_ns("id", XML_NS)` reads both; no code path in this
3970  /// workspace produces a literal-named "xml:id" attribute. The earlier
3971  /// claim here that constructed nodes carry the plain form does not
3972  /// reproduce. The 131 MB-book witness (2026-08-01: spill-time indexing
3973  /// registered 865 of 23,654 labels) was fixed by the commit that added
3974  /// this helper, but the missing-id mechanism was misattributed; the bare
3975  /// fallback is kept as belt (it is dead under the probed model, and
3976  /// harmless).
3977  pub(crate) fn node_xml_id_any_form(node: &Node) -> Option<String> {
3978    node
3979      .get_attribute_ns("id", XML_NS)
3980      .or_else(|| node.get_attribute("xml:id"))
3981  }
3982
3983  /// Record a spilled subtree's ids and labels in the fragment index, and
3984  /// purge every registry holding a handle or pointer into it.
3985  fn index_and_purge_spilled(
3986    &mut self,
3987    node: &Node,
3988    seg: crate::sxml::SegmentId,
3989    index: &mut crate::sxml::FragmentIndex,
3990  ) {
3991    // Box entries exist for TEXT nodes too (see purge_node_boxes_rec) —
3992    // purge before the element gate or a prose-heavy document leaks a
3993    // pinned `Digested` per spilled text node.
3994    self.node_boxes.remove(&node.to_hashable());
3995    if node.get_type() != Some(NodeType::ElementNode) {
3996      return;
3997    }
3998    for attr in [
3999      "about", "resource", "property", "typeof", "rel", "rev", "datatype",
4000    ] {
4001      if let Some(value) = node.get_attribute(attr) {
4002        for term in value.split_whitespace() {
4003          if let Some(colon) = term.find(':') {
4004            index.record_rdfa_prefix(&term[..colon], "");
4005          }
4006        }
4007      }
4008    }
4009    let id_opt = Self::node_xml_id_any_form(node);
4010    if let Some(id) = &id_opt {
4011      index.record_id(id, seg);
4012      self.unrecord_id(id);
4013      self.spilled_ids.insert(id.clone());
4014      if let Some(labels) = node.get_attribute("labels") {
4015        for label in labels.split_whitespace() {
4016          index.record_label(label, id);
4017        }
4018      }
4019    }
4020    for child in node.get_child_nodes() {
4021      self.index_and_purge_spilled(&child, seg, index);
4022    }
4023  }
4024
4025  // `strip_indentation_whitespace` lived here until `spill_flat` (its only
4026  // caller was streaming pass 2): it deleted the whitespace-only text nodes
4027  // our own pretty-printer had added to spill segments, via the same
4028  // `model::can_contain_sym(#PCDATA)` query the serializer indents by. Flat
4029  // spill serialization means those nodes are never created, so the
4030  // generate-then-undo pair is gone. It also `unlink_node`d rather than
4031  // freed — the orphan-leak trap — so do not resurrect it as-is.
4032
4033  /// Remove the pointer-keyed `node_boxes` entries for `node` and its whole
4034  /// subtree. MANDATORY before freeing nodes mid-conversion: a freed node's
4035  /// address is REUSED by later allocations, and a stale entry then
4036  /// mis-associates the old box (and its font) with an unrelated new node —
4037  /// observed as spurious `<text font="italic">` wrappers the moment the
4038  /// math parser started freeing replaced trees promptly.
4039  pub fn purge_node_boxes_rec(&mut self, node: &Node) {
4040    // EVERY node type can carry an entry: absorbing text records the box
4041    // against the TEXT node (`openText_internal`), and `get_node_box` never
4042    // reads those — but an unpurged one pins its whole `Digested` forever.
4043    // Measured: the dominant pass-1 creep on a 131 MB prose-heavy book
4044    // (~11 GB of orphaned text-node boxes; every probed collection flat).
4045    self.node_boxes.remove(&node.to_hashable());
4046    if node.get_type() != Some(NodeType::ElementNode) {
4047      return; // only elements have children to descend into
4048    }
4049    for child in node.get_child_nodes() {
4050      self.purge_node_boxes_rec(&child);
4051    }
4052  }
4053
4054  /// Discard a garbage subtree for good: purge the pointer-keyed
4055  /// `node_boxes` entries, then free the C memory NOW via the fork's
4056  /// `Node::free_subtree` — the missing half of every unlink-to-discard
4057  /// site (rust-libxml Linkage: a doc-created node that is unlinked but
4058  /// never re-attached is freed by NOBODY; math parsing discards thousands
4059  /// of replaced subtrees per document, measured ~1.4 MB/formula, ~1.8 GB
4060  /// retained per 32 MB streaming segment). `free_subtree` also
4061  /// neutralizes every live wrapper into the subtree, so stray clones in
4062  /// long-lived collections (`constructed_nodes`, an idstore epoch) go
4063  /// inert instead of firing a deferred `xmlFreeNode` after the document
4064  /// itself is gone.
4065  ///
4066  /// PRECONDITIONS (the caller's contract, satisfied at current sites):
4067  /// * every `xml:id` in the subtree is already unrecorded or transferred —
4068  ///   this deliberately does NOT `unrecord_node_ids`, because discard
4069  ///   always follows a copy (`append_tree`) that re-recorded the SAME id
4070  ///   strings for the copies, and unrecording here would kill those fresh
4071  ///   entries;
4072  /// * no live handle into the subtree is used afterwards.
4073  pub fn discard_subtree(&mut self, node: Node) {
4074    self.purge_node_boxes_rec(&node);
4075    node.free_subtree();
4076  }
4077
4078  /// Drop every `node_boxes` entry whose node is no longer IN this document
4079  /// tree (streaming pass 1's self-healing sweep). Entries are written for
4080  /// every constructed node and purged at the spill/discard chokepoints —
4081  /// but dozens of build-time discard paths (alignment rearrangement above
4082  /// all: 105k `align` environments on the 131 MB witness) detach nodes
4083  /// without purging, and each stale entry pins a whole `Digested` box tree.
4084  /// Measured: ~518k stale entries before the FIRST spill, growing past
4085  /// 1.75M — the dominant residual pass-1 creep after the C-side frees.
4086  /// A mark-and-retain against the live tree is immune to every such path,
4087  /// including future ones. Runs when the map is large; the post-spill
4088  /// spine is small, so the mark phase is cheap.
4089  pub fn sweep_stale_node_boxes(&mut self) {
4090    fn mark(node: &Node, live: &mut rustc_hash::FxHashSet<usize>) {
4091      live.insert(node.to_hashable());
4092      if node.get_type() == Some(NodeType::ElementNode) {
4093        for child in node.get_child_nodes() {
4094          mark(&child, live);
4095        }
4096      }
4097    }
4098    let mut live: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
4099    if let Some(root) = self.document.get_root_element() {
4100      mark(&root, &mut live);
4101    }
4102    self.node_boxes.retain(|k, _| live.contains(k));
4103  }
4104
4105  /// Attach the processed-segment store for serialization: from here on,
4106  /// `serialize_into` splices each segment's output text where its
4107  /// placeholder sits (streaming assembly).
4108  pub fn set_spill_store(&mut self, store: crate::sxml::SegmentStore) {
4109    self.spill_store = Some(store);
4110  }
4111
4112  /// Streaming pass 1 switch: defer (true) or restore (false) the ROOT
4113  /// element's `after_open` hook dispatch. See the field docs.
4114  pub fn set_defer_root_after_open(&mut self, defer: bool) { self.defer_root_after_open = defer; }
4115
4116  /// Is the root's late-hook dispatch currently deferred (streaming pass 1)?
4117  /// Engine bindings consult this to defer digestion-state-dependent
4118  /// insertions (frontmatter) to end-of-digestion.
4119  pub fn root_after_open_deferred(&self) -> bool { self.defer_root_after_open }
4120
4121  /// Open a `ltx:_Capture_` wrapper at the TOP of the root — after the last
4122  /// existing `ltx:resource` child (resources lead the document in the eager
4123  /// output), else as the first child. Shared insertion context for the
4124  /// streaming-mode operations that must place content where an
4125  /// empty-just-opened root would have put it.
4126  fn open_root_top_capture(&mut self) -> Result<Option<Node>> {
4127    let Some(mut root) = self.document.get_root_element() else {
4128      return Ok(None);
4129    };
4130    let mut last_resource: Option<Node> = None;
4131    for child in root.get_child_nodes() {
4132      if child.get_type() == Some(NodeType::ElementNode) && child.get_name() == "resource" {
4133        last_resource = Some(child);
4134      }
4135    }
4136    let wrapper = match last_resource {
4137      Some(r) => match r.get_next_sibling() {
4138        Some(next) => self.insert_element_before(&next, "ltx:_Capture_", None)?,
4139        None => self.open_element_at(&mut root, "ltx:_Capture_", None, None)?,
4140      },
4141      None => match root.get_first_child() {
4142        Some(first) => self.insert_element_before(&first, "ltx:_Capture_", None)?,
4143        None => self.open_element_at(&mut root, "ltx:_Capture_", None, None)?,
4144      },
4145    };
4146    Ok(Some(wrapper))
4147  }
4148
4149  /// Streaming pass 1: fold freshly queued resources into the live document
4150  /// as they arrive, at the top of the root. Perl's own contract
4151  /// (`Package.pm:RequireResource`): with a live document, `addResource`
4152  /// inserts DIRECTLY; only a document-less preamble queues. The eager path
4153  /// keeps the queue until the root's `after_open` because its build starts
4154  /// post-digestion; under streaming that drain is deferred to
4155  /// end-of-digestion, and mid-digestion consumers — the frontmatter
4156  /// fallback's `/ltx:document/ltx:resource[last()]` anchor — need the
4157  /// resources already placed.
4158  pub fn process_pending_resources_at_top(&mut self) -> Result<()> {
4159    let resources: Vec<Resource> = state::take_pending_resources();
4160    if resources.is_empty() {
4161      state::reset_pending_resources();
4162      return Ok(());
4163    }
4164    let Some(wrapper) = self.open_root_top_capture()? else {
4165      // No root yet: re-queue for the next absorb (or the deferred drain).
4166      for resource in resources {
4167        state::push_pending_resource(resource);
4168      }
4169      return Ok(());
4170    };
4171    let savenode = self.node.clone();
4172    self.set_node(&wrapper);
4173    for resource in resources {
4174      self.add_resource(resource)?;
4175    }
4176    state::reset_pending_resources();
4177    self.unwrap_nodes(wrapper)?;
4178    self.set_node(&savenode);
4179    Ok(())
4180  }
4181
4182  /// Streaming: dispatch the ROOT's deferred after-open hooks at
4183  /// end-of-digestion — the eager timing (build starts only after digestion
4184  /// ends) — with the insertion point at the TOP of the root, as if it had
4185  /// just opened empty. `after_open` itself cannot be used here: it pins the
4186  /// current node to the dispatched element, so everything the hooks insert
4187  /// (resources, frontmatter) would APPEND after the built content instead
4188  /// of leading it. A `ltx:_Capture_` wrapper as first child recreates the
4189  /// empty-root insertion context (the frontmatter fallback's own
4190  /// technique), and is unwrapped afterwards.
4191  pub fn dispatch_deferred_root_hooks(&mut self) -> Result<()> {
4192    debug_assert!(
4193      !self.defer_root_after_open,
4194      "clear the deferral before dispatching"
4195    );
4196    let Some(mut root) = self.document.get_root_element() else {
4197      return Ok(());
4198    };
4199    let savenode = self.node.clone();
4200    let Some(wrapper) = self.open_root_top_capture()? else {
4201      return Ok(());
4202    };
4203    self.set_node(&wrapper);
4204    let qname = get_node_qname(&root);
4205    let box_opt = self.get_node_box(&root);
4206    let (_prompt, late) = self.get_tag_action_list_parts(qname, TagOptionName::AfterOpen);
4207    for action in late {
4208      action(self, &mut root, box_opt.as_ref())?;
4209    }
4210    self.unwrap_nodes(wrapper)?;
4211    self.set_node(&savenode);
4212    Ok(())
4213  }
4214
4215  /// Merge RDFa prefixes recorded from spilled content, so the root's
4216  /// `prefix=` attribute covers usages the live-DOM scan can no longer see.
4217  pub fn add_extra_rdfa_prefixes<'a>(&mut self, prefixes: impl Iterator<Item = &'a str>) {
4218    self.extra_rdfa_prefixes.extend(prefixes.map(String::from));
4219  }
4220
4221  /// Detach the spill store (the streaming driver takes it out for pass 2 —
4222  /// which mutates segments while fragment documents exist independently —
4223  /// and re-attaches it for assembly).
4224  pub fn take_spill_store(&mut self) -> Option<crate::sxml::SegmentStore> {
4225    self.spill_store.take()
4226  }
4227
4228  /// Discard the in-memory `idstore` cache and rebuild it from the
4229  /// current DOM state. Historically guarded the 1605.08055 SIGSEGV
4230  /// where `mark_xmnode_visibility` dereferenced dangling lookup_id
4231  /// entries while recursing through XMRef targets.
4232  ///
4233  /// As of cycle 72, the 5 call sites that previously dropped nodes
4234  /// without unrecord_id — math-parser `replace_tree` at
4235  /// parser.rs:456/690 (cascades via remove_node) and `unbind_node`
4236  /// loops at parser.rs:639/856 + rewrite.rs:522 (all have
4237  /// preceding unrecord_node_ids guards) — are ID-safe. This rebuild
4238  /// is retained as a belt-and-suspenders probe until the
4239  /// 1605.08055 verification per SYNC_STATUS.md D3b lands.
4240  ///
4241  /// The rebuild is a DOM walk, so live id uniqueness is restored
4242  /// alongside — duplicates already in DOM are resolved with
4243  /// `modify_id`, matching `record_node_ids` semantics.
4244  pub fn rebuild_idstore_from_dom(&mut self) -> Result<()> {
4245    self.idstore.clear();
4246    if let Some(root) = self.document.get_root_element() {
4247      self.record_node_ids(&root)?;
4248    }
4249    Ok(())
4250  }
4251
4252  /// Get a new, related, but unique id.
4253  /// Sneaky option: try "ID_SUFFIX" as a suffix for id, first.
4254  /// Perl: sub modifyID (Document.pm lines 1483-1494)
4255  pub fn modify_id(&mut self, id: String) -> String {
4256    if self.idstore.contains_key(&id) || self.spilled_ids.contains(&id) {
4257      // Whoops! Already assigned!!!
4258      // Can we recover?
4259      let badid = id;
4260      // First try ID_SUFFIX if set
4261      if let Some(Stored::String(suffix)) = state::lookup_value("ID_SUFFIX") {
4262        let suffixed = s!("{}{}", badid, arena::to_string(suffix));
4263        if !self.idstore.contains_key(&suffixed) && !self.spilled_ids.contains(&suffixed) {
4264          return suffixed;
4265        }
4266      }
4267      // Try radix_alpha(1) through radix_alpha(26^3)
4268      // Gotta give up, eventually; is 3 letters enough?
4269      for s1 in 1_i64..=(26 * 26 * 26) {
4270        let candidate = s!("{}{}", badid, radix_alpha(s1));
4271        if !self.idstore.contains_key(&candidate) && !self.spilled_ids.contains(&candidate) {
4272          return candidate;
4273        }
4274      }
4275      emit_error(
4276        "malformed",
4277        "id",
4278        &format!("Automatic incrementing of ID counters failed for '{badid}'"),
4279      );
4280      badid
4281    } else {
4282      id
4283    }
4284  }
4285
4286  pub fn lookup_id(&self, id: &str) -> Option<&Node> { self.idstore.get(id) }
4287
4288  /// Clone the idstore for use in thread-local contexts (math parsing).
4289  pub fn get_idstore_clone(&self) -> HashMap<String, Node> { self.idstore.clone() }
4290
4291  // ======================================================================
4292  //  Odd bit:
4293  //  In an XMDual, in each branch (content, presentation) there will be atoms
4294  //  that correspond to the input (one will be real, the other an XMRef to the first).
4295  //  But also there will be additional "decoration" (delimiters, punctuation, etc on the
4296  // presentation  side; other symbols, bindings, whatever, on the content side).
4297  //  These decorations should NOT be subject to rewrite rules,
4298  //  and in cross-linked parallel markup, they should be attributed to the
4299  //  upper containing object's ID, rather than left dangling.
4300  //
4301  //  To determine this, we mark all math nodes as to whether they are "visible" from
4302  //  presentation, content or both (the default top-level being both).
4303  //  Decorations are the nodes that are visible to only one mode.
4304  //  Note that nodes that are not visible at all CAN occur (& do currently when the parser
4305  //  creates XMDuals), pruneXMDuals (below) gets rid of them.
4306
4307  // NOTE: This should ultimately be in a base Document class,
4308  // since it is also needed before conversion to parallel markup!
4309  pub fn mark_xmnode_visibility(&mut self) -> Result<()> {
4310    let xmath = self.findnodes("//ltx:XMath/*", None);
4311    for math in xmath.iter() {
4312      for mut node in self.findnodes("descendant-or-self::*[@_pvis or @_cvis]", Some(math)) {
4313        node.remove_attribute("_pvis")?;
4314        node.remove_attribute("_cvis")?;
4315      }
4316    }
4317    for math in xmath {
4318      self.mark_xmnode_visibility_aux(math, true, true)?;
4319    }
4320    Ok(())
4321  }
4322
4323  fn mark_xmnode_visibility_aux(&self, node: Node, cvis: bool, pvis: bool) -> Result<()> {
4324    // Recurses to math-tree depth via XMDual/XMRef-following + element-
4325    // child fan-out. Deep grammar-ambiguous papers (sandbox 0711.4787
4326    // et al, #17) hit Rust's 8 MB main-thread stack here during the
4327    // `Finalizing...` phase (via prune_xmduals → mark_xmnode_visibility).
4328    // Grow the stack on demand instead of overflowing (guard params are
4329    // configurable in `crate::stack_guard`).
4330    crate::stack_guard::maybe_grow(move || self.mark_xmnode_visibility_aux_inner(node, cvis, pvis))
4331  }
4332
4333  fn mark_xmnode_visibility_aux_inner(
4334    &self,
4335    mut node: Node,
4336    cvis: bool,
4337    mut pvis: bool,
4338  ) -> Result<()> {
4339    if (!cvis || node.has_attribute("_cvis")) && (!pvis || node.has_attribute("_pvis")) {
4340      return Ok(());
4341    }
4342    let qname = get_node_qname(&node);
4343    // Special case: for XMArg used to wrap "formal" arguments on the content side,
4344    // mark them as visible as presentation as well.
4345    if cvis && (qname == pin!("ltx:XMArg")) {
4346      pvis = true;
4347    }
4348    if cvis {
4349      node.set_attribute("_cvis", "1")?;
4350    }
4351    if pvis {
4352      node.set_attribute("_pvis", "1")?;
4353    }
4354    if qname == pin!("ltx:XMDual") {
4355      let mut children = xml::element_nodes(&node);
4356      // XMDual should have exactly 2 element children (content + presentation),
4357      // but a malformed math parse (e.g. semantic action producing empty pair
4358      // during deep ambiguity collapse) can leave an empty XMDual. Skip
4359      // visibility-marking rather than panicking on `children.remove(0)` —
4360      // see wp5 sandbox 2110.10033 and 4 sibling papers.
4361      if children.len() >= 2 {
4362        let c = children.remove(0);
4363        let p = children.remove(0);
4364        if cvis {
4365          self.mark_xmnode_visibility_aux(c, true, false)?;
4366        }
4367        if pvis {
4368          self.mark_xmnode_visibility_aux(p, false, true)?;
4369        }
4370      }
4371    } else if qname == pin!("ltx:XMRef") {
4372      match node.get_attribute("idref") {
4373        None => {
4374          let key = node.get_attribute("_xmkey");
4375          Warn!(
4376            "expected",
4377            "id",
4378            "Missing idref on ltx:XMRef",
4379            s!("_xmkey is `{}`", key.unwrap_or_default())
4380          );
4381        },
4382        Some(id) => match self.lookup_id(&id) {
4383          None => {
4384            Warn!(
4385              "expected",
4386              "node",
4387              s!("No node found with id='{id}' (referred to from ltx:XMRef)")
4388            );
4389          },
4390          Some(reffed) => {
4391            self.mark_xmnode_visibility_aux(reffed.clone(), cvis, pvis)?;
4392          },
4393        },
4394      }
4395    } else {
4396      for child in xml::element_nodes(&node) {
4397        self.mark_xmnode_visibility_aux(child, cvis, pvis)?;
4398      }
4399    }
4400    Ok(())
4401  }
4402
4403  /// Remove `ltx:XMRef[@_split_ref="1"]` whose `idref` no longer
4404  /// resolves. These are the XMRefs minted by
4405  /// `amsmath::rearrange_ams_split` to mirror the flattened cell
4406  /// sequence inside an `XMDual(XMWrap(refs), XMArray(cells))`. The
4407  /// math parser can later absorb some cells (typically inserted
4408  /// MULOP times-ops on `\mathcal{L}\rho` chains) into wrapping
4409  /// XMApps, dropping their xml:id from the live DOM and leaving
4410  /// the sibling XMRefs dangling. Left in place, each dangling
4411  /// XMRef trips three separate diagnostics later — math parser's
4412  /// read_xmref Warn, finalize's mark_xmnode_visibility Warn, and
4413  /// post-process's mark_xm_node_visibility Error.
4414  ///
4415  /// We restrict the sweep to the `_split_ref` marker so refs from
4416  /// other provenance (base_xmath `\lx@dual`, renamed-id cases like
4417  /// declare_test's `S1.Ex1.m1.1` → `.1a` rename) stay untouched.
4418  ///
4419  /// Content-preserving: XMRefs are structural cross-references,
4420  /// not author body, and the math parser has already absorbed the
4421  /// referenced cell into the visible XMArray branch — no glyph or
4422  /// formula material is lost.
4423  pub fn prune_dangling_split_xmrefs(&mut self) -> Result<()> {
4424    let xmrefs = self.findnodes("//ltx:XMRef[@_split_ref or @_mf_ref]", None);
4425    for xmref in xmrefs {
4426      let idref = match xmref.get_attribute("idref") {
4427        Some(id) => id,
4428        None => continue,
4429      };
4430      if self.lookup_id(&idref).is_none() && xmref.get_parent().is_some() {
4431        self.remove_node(xmref);
4432      }
4433    }
4434    // Broader sweep: any XMRef pointing to a canonical math node id
4435    // `S<N>.E<M>.m1.<K>...` that no longer resolves. These are minted
4436    // by base_xmath::add_column_to_math_fork during rearrange_ams_*
4437    // (align/gather/multline), but unlike `_split_ref` they aren't
4438    // marked. The math parser absorbs cells later, leaving the refs
4439    // dangling and triggering the `Error:expected:id` cascade in
4440    // post-processing.
4441    //
4442    // We restrict the regex to the equation-numbered form (E<digit>,
4443    // not Ex<digit>) so declare_test's renamed-id case
4444    // (`S1.Ex1.m1.1` → `.1a`) stays untouched. canvas papers using
4445    // `\begin{equation}` produce `E1`/`E2`/... ids.
4446    static RE_MATH_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S\d+\.E\d+\.m\d+\.").unwrap());
4447    let xmrefs2 = self.findnodes("//ltx:XMRef[@idref]", None);
4448    for xmref in xmrefs2 {
4449      // Skip if already pruned via _split_ref sweep above.
4450      if xmref.get_parent().is_none() {
4451        continue;
4452      }
4453      let idref = match xmref.get_attribute("idref") {
4454        Some(id) => id,
4455        None => continue,
4456      };
4457      if RE_MATH_ID.is_match(&idref) && self.lookup_id(&idref).is_none() {
4458        // Safety guard: NEVER drop a ref that is an OPERAND of an XMApp.
4459        // Removing it would corrupt the application — e.g. an `\lx@dual`
4460        // content arm `XMApp(probability, XMRef)` would lose its argument,
4461        // emitting a malformed `apply(probability)` with no operand (silent
4462        // content-MathML corruption; witnessed on `aligned`+`\Pr(a,b|c)`,
4463        // docs/reproducers/class_b_aligned_pr_xmref.tex). Such content-arm
4464        // refs are essential shared links — they are NEVER the redundant
4465        // MathFork/`_split_ref` mirrors this sweep targets (those live in
4466        // XMWrap/XMArray, not as XMApp operands). Leave it dangling so the
4467        // faithful "No node found" Warn (`mark_xmnode_visibility`) fires
4468        // rather than silently corrupting the content tree.
4469        if let Some(parent) = xmref.get_parent()
4470          && get_node_qname(&parent) == pin!("ltx:XMApp")
4471        {
4472          let kids = parent.get_child_elements();
4473          let is_operand = kids
4474            .iter()
4475            .position(|k| *k == xmref)
4476            .is_some_and(|pos| pos > 0);
4477          if is_operand {
4478            continue;
4479          }
4480        }
4481        self.remove_node(xmref);
4482      }
4483    }
4484    Ok(())
4485  }
4486
4487  /// Reduce any ltx:XMDual's to just the visible branch, if the other is not visible
4488  /// (according to markXMNodeVisibility)
4489  /// If we could be 100% sure that the marking had stayed consistent (after various doc surgery)
4490  /// we could avoid re-marking, but we'd better be sure before removing nodes!
4491  pub fn prune_xmduals(&mut self) -> Result<()> {
4492    // RE-mark visibility!
4493    self.mark_xmnode_visibility()?;
4494    // will reversing keep from problems removing nodes from trees that already have been removed?
4495    for dual in self
4496      .findnodes("descendant-or-self::ltx:XMDual", None)
4497      .into_iter()
4498      .rev()
4499    {
4500      self.document.node_to_string(&dual);
4501      let mut dual_children = xml::element_nodes(&dual);
4502      // Defensive: an XMDual should always have presentation +
4503      // content children, but a malformed math parse (post-ambiguity
4504      // collapse) can yield <2. Skip rather than panic.
4505      // Witness 2110.10033 (panicked at document.rs:3120, post-fix
4506      // continuation of earlier guard at document.rs:2993).
4507      let Some(presentation) = dual_children.pop() else {
4508        continue;
4509      };
4510      let Some(content) = dual_children.pop() else {
4511        continue;
4512      };
4513      if self
4514        .findnode("descendant-or-self::*[@_pvis or @_cvis]", Some(&content))
4515        .is_none()
4516      {
4517        // content never seen
4518        self.collapse_xmdual(dual, presentation)?;
4519      } else if self
4520        .findnode(
4521          "descendant-or-self::*[@_pvis or @_cvis]",
4522          Some(&presentation),
4523        )
4524        .is_none()
4525      {
4526        // pres.
4527        self.collapse_xmdual(dual, content)?;
4528      } else {
4529        // compact aligned structures, where possible
4530        self.compact_xmdual(dual, content, Some(presentation))?;
4531      }
4532    }
4533    Ok(())
4534  }
4535
4536  fn compact_xmdual(
4537    &mut self,
4538    dual: Node,
4539    content: Node,
4540    presentation: Option<Node>,
4541  ) -> Result<()> {
4542    // Perl: our $content_transfer_overrides = { decl_id, meaning, name, omcd };
4543    // Perl: our $dual_transfer_overrides = { decl_id, meaning, name, omcd, xml:id, role };
4544    static CONTENT_TRANSFER: Lazy<HashSet<&'static str>> =
4545      Lazy::new(|| HashSet::from_iter(["decl_id", "meaning", "name", "omcd"]));
4546    static DUAL_TRANSFER: Lazy<HashSet<&'static str>> =
4547      Lazy::new(|| HashSet::from_iter(["decl_id", "meaning", "name", "omcd", "xml:id", "role"]));
4548
4549    let presentation = match presentation {
4550      Some(p) => p,
4551      None => return Ok(()),
4552    };
4553    let c_name = with_node_qname(&content, |n| n.to_string());
4554    let p_name = with_node_qname(&presentation, |n| n.to_string());
4555
4556    // Case 1: Quick fix — merge two tokens (Perl compactXMDual L1588-1593).
4557    // Perl has NO id cleanup here: mergeAttributes deliberately TRANSFERS the
4558    // content/dual xml:id onto the surviving presentation token (unRecordID +
4559    // recordID, L1308-1313), keeping refs to that id resolvable. A former
4560    // Rust-only "remove the leaked content id" block contradicted that — and
4561    // was dead anyway (its bare has/get/remove_attribute("xml:id") calls
4562    // always missed the namespaced attribute).
4563    if c_name == "ltx:XMTok" && p_name == "ltx:XMTok" {
4564      let mut pres = presentation;
4565      self.merge_attributes(&content, &mut pres, Some(&CONTENT_TRANSFER))?;
4566      self.merge_attributes(&dual, &mut pres, Some(&DUAL_TRANSFER))?;
4567      // Unlink presentation from dual before replacing, since presentation is a child of dual
4568      pres.unlink();
4569      self.replace_node(dual, vec![pres])?;
4570      return Ok(());
4571    }
4572
4573    // Case 2: Compact mirror XMApp nodes
4574    if c_name != "ltx:XMApp" || p_name != "ltx:XMApp" {
4575      return Ok(());
4576    }
4577    let content_args = xml::element_nodes(&content);
4578    let pres_args = xml::element_nodes(&presentation);
4579    if content_args.len() != pres_args.len() {
4580      return Ok(());
4581    }
4582    let n_args = content_args.len();
4583
4584    // Walk the corresponding children, double-check they are referenced in the same order
4585    enum NewArg {
4586      Single(Node),
4587      Pair(Node, Node), // (content_arg, pres_arg) — to be merged
4588    }
4589    let mut new_args: Vec<NewArg> = Vec::with_capacity(n_args);
4590    for (c_arg, p_arg) in content_args.into_iter().zip(pres_args) {
4591      if let Some(c_idref) = c_arg.get_attribute("idref")
4592        && c_idref == p_arg.get_attribute_ns("id", XML_NS).unwrap_or_default()
4593      {
4594        new_args.push(NewArg::Single(p_arg));
4595        continue;
4596      }
4597      if let Some(p_idref) = p_arg.get_attribute("idref")
4598        && p_idref == c_arg.get_attribute_ns("id", XML_NS).unwrap_or_default()
4599      {
4600        new_args.push(NewArg::Single(c_arg));
4601        continue;
4602      }
4603      // We can handle content-side XMToks to any XM* presentation subtree
4604      let c_arg_name = with_node_qname(&c_arg, |n| n.to_string());
4605      if c_arg_name != "ltx:XMTok" {
4606        return Ok(()); // Can't compact this structure
4607      }
4608      new_args.push(NewArg::Pair(c_arg, p_arg));
4609    }
4610
4611    // If we made it here, this dual has two mirrored applications — compact it.
4612    let mut parent = match dual.get_parent() {
4613      Some(p) => p,
4614      None => return Ok(()),
4615    };
4616    let mut compact_apply = self.open_element_at(&mut parent, "ltx:XMApp", None, None)?;
4617    for n_arg in new_args {
4618      let mut node = match n_arg {
4619        NewArg::Single(n) => n,
4620        NewArg::Pair(c_arg, mut p_arg) => {
4621          self.merge_attributes(&c_arg, &mut p_arg, Some(&CONTENT_TRANSFER))?;
4622          p_arg
4623        },
4624      };
4625      node.unlink();
4626      compact_apply.add_child(&mut node)?;
4627    }
4628    // Migrate dual attributes to the new XMApp
4629    self.merge_attributes(&dual, &mut compact_apply, Some(&DUAL_TRANSFER))?;
4630    // Direct DOM swap: replace dual with compact_apply without re-creating nodes.
4631    // Perl uses replaceChild which is a direct swap; replace_tree calls append_tree
4632    // which re-creates elements and fires afterOpen/afterClose hooks a second time.
4633    compact_apply.unlink();
4634    let mut dual_mut = dual;
4635    dual_mut.add_prev_sibling(&mut compact_apply).ok();
4636    self.remove_node(dual_mut);
4637    Ok(())
4638  }
4639
4640  /// Replace an XMDual with one of its branches
4641  fn collapse_xmdual(&mut self, dual: Node, mut branch: Node) -> Result<()> {
4642    // The other branch is not visible, nor referenced,
4643    // but the dual may have an id and be referenced
4644    if let Some(dualid) = dual.get_attribute_ns("id", XML_NS) {
4645      self.unrecord_id(&dualid); // We'll move or remove the ID from the dual
4646      if let Some(branchid) = branch.get_attribute_ns("id", XML_NS) {
4647        // branch has id too!
4648        for mut tref in self.findnodes(&s!("//*[@idref='{}']", dualid), None) {
4649          tref.set_attribute("idref", &branchid)?;
4650        } // Change dualid refs to branchid
4651      } else {
4652        // Assign the dual's id to the branch. Record first so we
4653        // receive a deduplicated id if something else claimed it
4654        // between the `unrecord_id` above and now — write the
4655        // deduped value, not the original.
4656        let deduped = self.record_id_with_node(&dualid, &branch);
4657        branch.set_attribute("xml:id", &deduped)?;
4658      }
4659    }
4660    // Direct DOM swap: Perl uses replaceChild (no re-creation, no hooks fired twice)
4661    let mut dual_mut = dual;
4662    dual_mut.add_prev_sibling(&mut branch).ok();
4663    self.remove_node(dual_mut);
4664    Ok(())
4665  }
4666
4667  //**********************************************************************
4668  /// Record the Box that created this node.
4669  pub fn set_node_box(&mut self, node: &Node, digested: Digested) {
4670    let nodeid = node.to_hashable();
4671    self.node_boxes.insert(nodeid, digested);
4672  }
4673
4674  pub fn get_node_box(&self, node: &Node) -> Option<Digested> {
4675    if node.get_type() == Some(NodeType::ElementNode) {
4676      let nodeid = node.to_hashable();
4677      self.node_boxes.get(&nodeid).cloned()
4678    } else {
4679      None
4680    }
4681  }
4682
4683  //**********************************************************************
4684  /// Record the Font of a node
4685  pub fn set_node_font(&mut self, node: &mut Node, font: &Font) -> Result<()> {
4686    let fontid = font.to_hashable();
4687    node.set_attribute("_font", &fontid.to_string())?;
4688    // try to avoid aggressive clones, when unnecessary
4689    match self.node_fonts.get(&fontid) {
4690      None => {
4691        self.node_fonts.insert(fontid, font.clone());
4692      },
4693      Some(v) => {
4694        if v != font {
4695          self.node_fonts.insert(fontid, font.clone());
4696        }
4697      },
4698    }
4699    Ok(())
4700  }
4701
4702  pub fn copy_node_font(&mut self, from: &Node, to: &mut Node) -> Result<()> {
4703    if let Some(fontid) = from.get_attribute("_font") {
4704      to.set_attribute("_font", &fontid)?;
4705    }
4706    Ok(())
4707  }
4708
4709  /// Possibly a sign of a design flaw; Set the node's font & all children that HAD the same font.
4710  pub fn merge_node_font_rec(&mut self, node: &Node, font: &Font) -> Result<()> {
4711    let oldfont = self.get_node_font(node);
4712    let props = oldfont.purestyle_changes(font);
4713    let mut nodes = VecDeque::new();
4714    nodes.push_front(node.clone());
4715    while let Some(mut n) = nodes.pop_front() {
4716      if n.get_type() == Some(NodeType::ElementNode) {
4717        let font = &self.get_node_font(&n).merge_ref(&props);
4718        self.set_node_font(&mut n, font)?;
4719        for child in n.get_child_nodes() {
4720          nodes.push_back(child);
4721        }
4722      }
4723    }
4724    Ok(())
4725  }
4726
4727  pub fn set_box_font(&mut self, node: &mut Node) -> Result<()> {
4728    if let Some(ref thisbox) = self.box_to_absorb
4729      && let Some(font) = thisbox.get_font()?
4730    {
4731      let todo_font_clone = (*font).clone();
4732      self.set_node_font(node, &todo_font_clone)?;
4733    }
4734    Ok(())
4735  }
4736
4737  pub fn get_node_font(&self, node: &Node) -> &Font {
4738    if let Some(element) = xml::closest_element(node) {
4739      // Use the closest element (for text nodes, this is the parent element)
4740      if let Some(fontid) = element.get_attribute("_font") {
4741        // Tolerate non-numeric `_font` attributes — they can occur when
4742        // a corrupted property propagates across reversion (driver:
4743        // 2304.07380 panicked at parse::<u64>().unwrap()). Fall through
4744        // to the default font instead of aborting the run.
4745        if let Ok(id) = fontid.parse::<u64>()
4746          && let Some(fnt) = self.node_fonts.get(&id)
4747        {
4748          return fnt;
4749        }
4750      }
4751    }
4752    &FONT_TEXT_DEFAULT
4753  }
4754
4755  /// Decode a _font hash string to a Font object
4756  pub fn decode_font(&self, font_hash: &str) -> Option<&Font> {
4757    font_hash
4758      .parse::<u64>()
4759      .ok()
4760      .and_then(|id| self.node_fonts.get(&id))
4761  }
4762
4763  pub fn has_node_font(&self, node: &Node) -> bool {
4764    match xml::closest_element(node) {
4765      Some(element) => element.has_attribute("_font"),
4766      _ => false,
4767    }
4768  }
4769
4770  pub fn get_node_language(&self, node: &Node) -> String {
4771    let mut node_ref = node;
4772    let mut current;
4773    loop {
4774      if node_ref.get_type() != Some(NodeType::ElementNode) {
4775        break;
4776      }
4777      // `xml:lang` is stored namespaced (local name "lang"); the string
4778      // accessor `get_attribute("xml:lang")` always returns None — read it via
4779      // the XML namespace. (Same libxml footgun as xml:id; see
4780      // docs/archive/XMLID_ACCESSOR_AUDIT_2026-06-08.md.)
4781      if let Some(lang) = node_ref.get_attribute_ns("lang", XML_NS) {
4782        return lang;
4783      }
4784      // Perl `getNodeLanguage` reads `_font` off the SAME (walked) ancestor,
4785      // not the original node — use `node_ref`, not `node`. Be robust against a
4786      // missing/foreign _font property (Perl PR #2767): never panic on a
4787      // non-numeric font id.
4788      if let Some(fontid) = node_ref.get_attribute("_font")
4789        && let Some(font) = fontid
4790          .parse::<u64>()
4791          .ok()
4792          .and_then(|id| self.node_fonts.get(&id))
4793        && let Some(lang) = font.get_language()
4794      {
4795        return lang.to_string();
4796      }
4797      match node_ref.get_parent() {
4798        Some(parent) => {
4799          current = parent;
4800          node_ref = &current;
4801        },
4802        _ => {
4803          break;
4804        },
4805      }
4806    }
4807    String::from("en")
4808  }
4809
4810  // sub decodeFont {
4811  //   my ($self, $fontid) = @_;
4812  //   return $$self{node_fonts}{$fontid} || LaTeXML::Common::Font->textDefault(); }
4813
4814  /// Remove a node from the document (from it's parent).
4815  ///
4816  /// Two pieces of bookkeeping come with it: the `xml:id` of the node and of
4817  /// every descendant is un-recorded, so the ids are free for reuse and no
4818  /// dangling reference is left behind; and if the insertion point was inside
4819  /// what is being removed, it is rescued up to the parent — otherwise the
4820  /// document would go on building into a detached subtree.
4821  pub fn remove_node(&mut self, mut node: Node) {
4822    let mut chopped: bool = self.node == node; // Note if we're removing insertion point
4823    if node.get_type() == Some(NodeType::ElementNode) {
4824      // If an element, do ID bookkeeping.
4825      if let Some(id) = node.get_attribute_ns("id", XML_NS) {
4826        self.unrecord_id(&id);
4827      }
4828      for child in node.get_child_nodes() {
4829        chopped = chopped || self.remove_node_aux(child);
4830      }
4831    }
4832    if let Some(parent) = node.get_parent() {
4833      if chopped {
4834        // Don't remove insertion point!
4835        self.set_node(&parent);
4836      }
4837      node.unlink();
4838    }
4839  }
4840
4841  fn remove_node_aux(&mut self, node: Node) -> bool {
4842    let mut chopped = self.node == node;
4843    if node.get_type() == Some(NodeType::ElementNode) {
4844      // If an element, do ID bookkeeping.
4845      if let Some(id) = node.get_attribute_ns("id", XML_NS) {
4846        self.unrecord_id(&id);
4847      }
4848      for child in node.get_child_nodes() {
4849        chopped = chopped || self.remove_node_aux(child);
4850      }
4851    }
4852    chopped
4853  }
4854
4855  //**********************************************************************
4856  // Inserting new nodes at random points into the document,
4857  // typically, later in the process or during some kind of rearrangement.
4858
4859  // This is a somewhat strange situation; There are commands and environments
4860  // that do some interesting thing to their contents. This include things like
4861  // center, flushleft, or rotate, or ...
4862  // Naively one is tempted to create a containing block with appropriate type &
4863  // attributes. However, since these things can be allowed in so many places
4864  // by LaTeX, that one has a difficult time creating a sensible document model.
4865  // The purpose of transformingBlock is to set the contents (possibly creating a
4866  // consistent <p> around them, if called for), and returning the list of newly
4867  // created nodes. These nodes can then have appropriate attributes added as
4868  // needed for each specific case.
4869
4870  // Since this situation can occur in both LaTeX and AmSTeX type documents,
4871  // we'll put it in the TeX pool so it can be reused.
4872
4873  // Tricky bit for creating nodes late in the game,
4874
4875  ////// See createElementAt
4876  /// This opens a new element at the _specified_ point, rather than the current insertion point.
4877  /// This is useful during document rearrangement or augmentation that may be needed later
4878  /// in the process.
4879  pub fn open_element_at(
4880    &mut self,
4881    point: &mut Node,
4882    qname: &str,
4883    attributes: Option<HashMap<String, String>>,
4884    mut font_opt: Option<Font>,
4885  ) -> Result<Node> {
4886    // Font resolution priority (matching Perl's openElement/openElementAt):
4887    // 1. Explicit font_opt parameter
4888    // 2. _font attribute in attributes hash
4889    // 3. box_to_absorb.get_font() — the font of the current box being absorbed (Perl:
4890    //    $attributes{_box} = $LaTeXML::BOX; $font = $attributes{_box}->getFont)
4891    // 4. Insertion point's font (final fallback, handled later)
4892    if font_opt.is_none()
4893      && let Some(ref attrs) = attributes
4894      && let Some(fontid) = attrs.get("_font")
4895    {
4896      // Tolerate non-numeric `_font` attributes — see get_node_font
4897      // for the same defensive read; same panic site, different
4898      // call. Driver: 2406.14188.
4899      if let Ok(id) = fontid.parse::<u64>() {
4900        font_opt = self.node_fonts.get(&id).cloned();
4901      }
4902    }
4903    if font_opt.is_none()
4904      && let Some(ref digested) = self.box_to_absorb
4905      && let Ok(Some(font)) = digested.get_font()
4906    {
4907      font_opt = Some((*font).clone());
4908    }
4909    let (decoded_ns, tag) = model::decode_qname(qname)?;
4910    let mut newnode;
4911    // box = self.node_boxes.get(box);    // may already be the string key
4912    // If this will be the document root node, things are slightly more involved.
4913    if point.get_type() == Some(NodeType::DocumentNode) {
4914      // First node! (?)
4915      Debug!("adding schema declaration, new node will be : {}", tag);
4916      model::add_schema_declaration(self);
4917      newnode = Node::new(&tag, None, &self.document).unwrap();
4918      self.record_constructed_node(&newnode);
4919      self.document.set_root_element(&newnode);
4920      for node in &mut self.pending {
4921        newnode.add_prev_sibling(node)?; // Add saved comments, PI's
4922      }
4923
4924      if let Some(ns) = decoded_ns {
4925        // Here, we're creating the initial, document element, which will hold ALL of
4926        // the namespace declarations. If there is a default namespace (no
4927        // prefix), that will also be declared, and applied here. However, if
4928        // there is ALSO a prefix associated with that namespace, we have to declare it
4929        // FIRST due to the (apparently) buggy way that XML::LibXML works with
4930        // namespaces in setAttributeNS.
4931        let prefix_opt = model::get_document_namespace_prefix(&ns, false, false);
4932        let attprefix_opt = model::get_document_namespace_prefix(&ns, true, true);
4933        if prefix_opt.is_none()
4934          && let Some(attprefix_sym) = attprefix_opt
4935        {
4936          let attr_ns_node = arena::with(attprefix_sym, |attprefix| {
4937            Namespace::new(attprefix, &ns, &mut newnode)
4938          })
4939          .unwrap();
4940          newnode.set_namespace(&attr_ns_node)?;
4941        }
4942        // TODO: Figure out a better way to achieve the "activate" effect in
4943        // XML:LibXML::Element it seems just creating the namespace without
4944        // setting it is equivalent ??
4945        let ns_node = Namespace::new("", &ns, &mut newnode).unwrap();
4946        newnode.set_namespace(&ns_node)?;
4947      }
4948    } else {
4949      if font_opt.is_none() {
4950        font_opt = Some(self.get_node_font(point).clone());
4951      }
4952      newnode = self.open_element_internal(point, decoded_ns, &tag)?;
4953    }
4954
4955    // Source-locator stamping (`--source-map`, issues #47/#92). `open_element_at`
4956    // is the shared element-creation primitive (plain `open_element`, math, and
4957    // alignment all route here), so stamping here covers them uniformly —
4958    // including the `ltx:Math` wrapper that bypasses `open_element`. Off by
4959    // default; the cheap gate keeps the normal path free.
4960    if state::source_map_enabled() {
4961      self.stamp_source_locator(&newnode, qname);
4962    }
4963
4964    if let Some(attrs) = attributes {
4965      let mut sorted_keys = attrs.keys().map(String::as_str).collect::<Vec<_>>();
4966      sorted_keys.sort_unstable();
4967      for key in sorted_keys {
4968        if key == "font" || key == "locator" {
4969          continue;
4970        }
4971        self.set_attribute(&mut newnode, key, &attrs[key])?;
4972      }
4973    }
4974    if let Some(font) = font_opt {
4975      self.set_node_font(&mut newnode, &font)?;
4976    }
4977
4978    // TODO [new]: Ever more certain there is a refactor waiting to happen with box_to_absorb
4979    //             holding a Rc<Digested> for easy cloning and management.
4980    //             Though the question remains how to maintain that, without cloning the box to
4981    // **make** the Rc<> Old note:
4982    // The .clone on boxes is potentially *VERY SLOW* and a code smell.
4983    // It can be eventually avoided by using a "memory arena" for all intermediate
4984    // objects - tokens, boxes, etc. and a well-designed referncing scheme into
4985    // the driver structs, such as Gullet, Stomach and Document
4986    if let Some(ref digested) = self.box_to_absorb {
4987      self.set_node_box(&newnode, digested.clone());
4988    }
4989
4990    // Debug!(
4991    //   s!("Inserting {:?} into {:?}", get_node_qname(&newnode), get_node_qname(point))
4992    // );
4993
4994    // Run afterOpen operations
4995    self.after_open(&mut newnode)?;
4996
4997    Ok(newnode)
4998  }
4999
5000  fn open_element_internal(
5001    &mut self,
5002    point: &mut Node,
5003    ns_opt: Option<String>,
5004    tag: &str,
5005  ) -> Result<Node> {
5006    // TODO:
5007    //
5008    // I am seriously irritated by the XML namespace and the confusion of the "default#"
5009    // tricks and libxml2's custom decisions about namespace interactions
5010    //
5011    // I have "hacked together" a working flow for now, but I expect to
5012    // encounter bugs related to the shortcuts taken here. I would welcome a
5013    // redesign       that simplifies the namespace logic dramatically.
5014    let new_ns = match ns_opt {
5015      Some(ns_uri) => {
5016        match point.lookup_namespace_prefix(&ns_uri) {
5017          // namespace not already declared?
5018          None => {
5019            if let Some(prefix) = model::get_document_namespace_prefix(&ns_uri, false, false) {
5020              if prefix != pin!("") {
5021                let mut root = self.document.get_root_element().unwrap();
5022                match arena::with(prefix, |prefix_str| {
5023                  Namespace::new(prefix_str, &ns_uri, &mut root)
5024                }) {
5025                  Ok(ns) => Some(ns),
5026                  Err(_) => {
5027                    // The namespace already exists on root (declared by an
5028                    // earlier element of the same namespace — e.g. a prior
5029                    // tikz/SVG picture) but `lookup_namespace_prefix` did not
5030                    // find it from this deeply-nested insertion point. Recover
5031                    // by reusing the root declaration (or creating it on the
5032                    // insertion point), exactly as the already-declared branch
5033                    // below — do NOT drop the namespace. Witness 1802.00756:
5034                    // a `tikzpicture` inside a nested `gather*`/`minipage`/
5035                    // `figure*` emitted 14× "failed to create namespace: svg"
5036                    // and the `<svg:svg>`/`<svg:g>` lost their namespace.
5037                    arena::with(prefix, |prefix_str| {
5038                      let found = root
5039                        .get_namespace_declarations()
5040                        .into_iter()
5041                        .find(|ns| ns.get_prefix() == prefix_str);
5042                      if found.is_none() {
5043                        Namespace::new(prefix_str, &ns_uri, point).ok()
5044                      } else {
5045                        found
5046                      }
5047                    })
5048                  },
5049                }
5050              } else {
5051                // default namespace?
5052                None
5053              }
5054            } else {
5055              // default namespace?
5056              None
5057            }
5058          },
5059          Some(prefix) => {
5060            if !prefix.is_empty() {
5061              let mut root = self.document.get_root_element().unwrap();
5062              match Namespace::new(&prefix, &ns_uri, &mut root) {
5063                Ok(ns) => Some(ns),
5064                Err(_) => {
5065                  // Namespace already exists on root — find and reuse it.
5066                  // We search declarations then fall back to creating on the
5067                  // insertion point (which inherits from root).
5068                  let found = root
5069                    .get_namespace_declarations()
5070                    .into_iter()
5071                    .find(|ns| ns.get_prefix() == prefix);
5072                  if found.is_none() {
5073                    // Try creating on the insertion point instead
5074                    Namespace::new(&prefix, &ns_uri, point).ok()
5075                  } else {
5076                    found
5077                  }
5078                },
5079              }
5080            } else {
5081              // default namespace?
5082              None
5083            }
5084          },
5085        }
5086      },
5087      None => None,
5088    };
5089
5090    let no_ns = new_ns.is_none();
5091    let mut newnode = match Node::new(tag, new_ns.clone(), &self.document) {
5092      Ok(n) => n,
5093      Err(_) => {
5094        // libxml2 rejected the tag (e.g. NUL byte, malformed name).
5095        // Bail out of element creation rather than aborting; caller
5096        // can recover. Driver: 2304.07380 panic at Node::new unwrap.
5097        let message = s!("failed to create element {:?}", tag);
5098        Error!("document", "open_element_internal", message);
5099        return Err(message.into());
5100      },
5101    };
5102    point.add_child(&mut newnode)?;
5103    if no_ns {
5104      // When no explicit namespace was determined (default namespace element),
5105      // try to find the root's default namespace first. This prevents inheriting
5106      // the parent's namespace when inside a different namespace context (e.g.,
5107      // SVG elements getting svg: prefix on LaTeXML elements like Math/XMath).
5108      let root_ns = self
5109        .document
5110        .get_root_element()
5111        .and_then(|r| r.get_namespace());
5112      let parent_ns = point.get_namespace();
5113      if let Some(ref rns) = root_ns {
5114        // Use root's namespace for default-namespace elements
5115        let _ = newnode.set_namespace(rns);
5116      } else if let Some(ns) = parent_ns {
5117        // Fallback: inherit from parent (original behavior)
5118        newnode.set_namespace(&ns)?;
5119      }
5120    } else if let Some(ref ns) = new_ns {
5121      // For explicitly namespaced elements (e.g., svg:svg), ensure the namespace
5122      // is set after add_child — Node::new may not properly bind the namespace
5123      // when the Namespace was retrieved from get_namespace_declarations().
5124      let _ = newnode.set_namespace(ns);
5125    }
5126
5127    self.record_constructed_node(&newnode);
5128    Ok(newnode)
5129  }
5130
5131  /// Whenever a node has been created using openElementAt,
5132  /// closeElementAt ought to be used to close it, when you're finished inserting into $node.
5133  /// Basically, this just runs any afterClose operations.
5134  pub fn close_element_at(&mut self, node: &mut Node) -> Result<()> { self.after_close(node) }
5135
5136  pub fn after_open(&mut self, node: &mut Node) -> Result<()> {
5137    // Streaming pass 1: the root's LATE hooks are deferred to
5138    // end-of-digestion (their eager semantics are "digestion complete") but
5139    // its early/normal hooks — structural work like the pending-resource
5140    // drain, which mid-digestion consumers depend on — still run at open.
5141    // See the `defer_root_after_open` field docs.
5142    if self.defer_root_after_open && self.document.get_root_element().as_ref() == Some(node) {
5143      let savenode = self.node.clone();
5144      self.set_node(node);
5145      let node_qname = get_node_qname(node);
5146      let box_opt = self.get_node_box(node);
5147      let (prompt, _late) = self.get_tag_action_list_parts(node_qname, TagOptionName::AfterOpen);
5148      for action in prompt {
5149        action(self, node, box_opt.as_ref())?;
5150      }
5151      self.set_node(&savenode);
5152      return Ok(());
5153    }
5154    // Set current point to this node, just in case the afterOpen's use it.
5155    let savenode = self.node.clone();
5156    self.set_node(node);
5157    let node_qname = get_node_qname(node);
5158    // Perl: my $box = getNodeBox($self, $node);
5159    let box_opt = self.get_node_box(node);
5160    for action in self.get_tag_action_list(node_qname, TagOptionName::AfterOpen) {
5161      action(self, node, box_opt.as_ref())?;
5162    }
5163    self.set_node(&savenode);
5164    Ok(())
5165  }
5166
5167  pub fn after_close(&mut self, node: &mut Node) -> Result<()> {
5168    // Should we set point to this node? (or to last child, or something ??
5169    let savenode = self.node.clone();
5170    let node_qname = get_node_qname(node);
5171    // Perl: my $box = getNodeBox($self, $node);
5172    let box_opt = self.get_node_box(node);
5173    for action in self.get_tag_action_list(node_qname, TagOptionName::AfterClose) {
5174      action(self, node, box_opt.as_ref())?;
5175    }
5176    self.set_node(&savenode);
5177    Ok(())
5178  }
5179
5180  //**********************************************************************
5181  // Appending clones of nodes
5182
5183  // Inserting clones of nodes into the document.
5184  // Nodes that exist in some other part of the document (or some other document)
5185  /// Append COPIES of `new_children` under `node`.
5186  ///
5187  /// Cloning rather than moving is what makes this usable on nodes that belong
5188  /// to another document — moving them would remove them from it. Three things
5189  /// are repaired on the way in: document fragments are expanded to their
5190  /// children, the namespace structure is rebuilt clean (libxml2 otherwise has
5191  /// a tendency to introduce annoying "default" namespace prefix
5192  /// declarations), and every `xml:id` in the copy is rewritten to a fresh id,
5193  /// with internal references remapped to match — otherwise the copy would
5194  /// duplicate the original's ids.
5195  // # Should have variants here for prepend, insert before, insert after.... ???
5196  pub fn append_clone(&mut self, node: &mut Node, new_children: Vec<Node>) -> Result<()> {
5197    // Expand any document fragments
5198    let new_children = new_children
5199      .into_iter()
5200      .flat_map(|child| {
5201        if child.get_type() == Some(NodeType::DocumentFragNode) {
5202          child.get_child_nodes()
5203        } else {
5204          vec![child]
5205        }
5206      })
5207      .collect::<Vec<Node>>();
5208    // Now find all xml:id's in the new_children and record replacement id's for them
5209    let mut id_map = HashMap::default();
5210    // Find all id's defined in the copy and change the id.
5211    // Note: XPath ".//@xml:id" can fail to find namespace-qualified attributes.
5212    // Use DOM walking as fallback to ensure all ids are found.
5213    for child in new_children.iter() {
5214      let mut xpath_ids: Vec<String> = self.findvalues(".//@xml:id", Some(child));
5215      if xpath_ids.is_empty() {
5216        // Fallback: walk DOM to find xml:id attributes
5217        Self::collect_xml_ids_from(child, &mut xpath_ids);
5218      }
5219      for id in xpath_ids {
5220        id_map.insert(id.clone(), self.modify_id(id));
5221      }
5222    }
5223    // Now do the cloning (actually copying) and insertion.
5224    self.append_clone_aux(node, new_children, &mut id_map)
5225  }
5226
5227  /// Walk DOM to collect xml:id attribute values (fallback when XPath fails).
5228  fn collect_xml_ids_from(node: &Node, ids: &mut Vec<String>) {
5229    if let Some(id) = node.get_attribute("xml:id") {
5230      ids.push(id);
5231    } else if let Some(id) = node.get_attribute_ns("id", XML_NS) {
5232      ids.push(id);
5233    }
5234    for child in node.get_child_nodes() {
5235      if child.get_type() == Some(NodeType::ElementNode) {
5236        Self::collect_xml_ids_from(&child, ids);
5237      }
5238    }
5239  }
5240
5241  fn append_clone_aux(
5242    &mut self,
5243    node: &mut Node,
5244    new_children: Vec<Node>,
5245    id_map: &mut HashMap<String, String>,
5246  ) -> Result<()> {
5247    for child in new_children.into_iter() {
5248      match child.get_type() {
5249        Some(NodeType::ElementNode) => {
5250          let mut new = self.open_element_internal(
5251            node,
5252            child.get_namespace().map(|ns| ns.get_href()),
5253            &child.get_name(),
5254          )?;
5255          for (key, val) in child.get_attributes() {
5256            match key.as_str() {
5257              "xml:id" | "id" => {
5258                // Use the replacement id. The pre-walk
5259                // (findvalues/collect_xml_ids_from) normally populates
5260                // id_map with every `xml:id` it can see, but the
5261                // namespace/prefix of the attribute key returned by
5262                // libxml2's `get_attributes()` can differ from what the
5263                // XPath / DOM walk picked up (e.g. when the incoming
5264                // node's tree came through a cloneNode that stripped
5265                // the xml namespace). Fall back to minting a fresh
5266                // replacement id on-the-fly rather than panicking —
5267                // 1410.8508 hit this when the pre-walk found zero ids
5268                // but attributes on a child node did carry xml:id.
5269                let fresh;
5270                let mapped_id = match id_map.get(&val) {
5271                  Some(id) => id,
5272                  None => {
5273                    fresh = self.modify_id(val.clone());
5274                    id_map.insert(val.clone(), fresh.clone());
5275                    id_map.get(&val).unwrap()
5276                  },
5277                };
5278                let newid = self.record_id_with_node(mapped_id, &new);
5279                // Write the literal "xml:id" key (not the bare "id" local-
5280                // name returned by libxml's get_attributes). Otherwise the
5281                // cloned node only gets a plain `id` attribute, and the
5282                // subsequent `after_open` chain's `has_attribute_ns("id",
5283                // XML_NS)` check returns false, causing `generate_id` to
5284                // mint a fresh `.<parent>.N` xml:id that doesn't match the
5285                // sibling XMRef idrefs (which were rewritten from id_map).
5286                // Witness: arXiv:2509.07628 — MathFork mainfork emitted
5287                // 154 XMRefs with `.mf` idrefs while the cloned target
5288                // nodes received parent-scoped `.m2.N` xml:ids, leaving
5289                // every XMRef dangling and triggering 4
5290                // `Error:expected:id` per equation during post-processing
5291                // visibility marking. Same shape applies anywhere a
5292                // cloned subtree carries xml:id attributes — MathFork,
5293                // tabular-cell clone, _Capture_ flush.
5294                new.set_attribute("xml:id", &newid)?;
5295                // Update id_map so subsequent idref lookups use the ACTUAL recorded id.
5296                // record_id_with_node may change the id (e.g., if there are conflicts),
5297                // so the mapped_id and newid may differ.
5298                if *mapped_id != newid {
5299                  id_map.insert(val.clone(), newid);
5300                }
5301              },
5302              "idref" => {
5303                // Refer to the replacement id if it was replaced
5304                let id = id_map.get(&val).unwrap_or(&val);
5305                new.set_attribute(&key, id)?;
5306              },
5307              other_key =>
5308              // TODO: Are namespaced attributes successfully handled here? Check.
5309              {
5310                new.set_attribute(other_key, &val)?
5311              },
5312            };
5313          }
5314          // Carry the source node's box onto the clone, matching Perl: its
5315          // `cloneNode` copies the internal `_box` attribute, so `getNodeBox`
5316          // still resolves on the clone. Our node box is a side map keyed by
5317          // node identity, so a fresh clone is box-less unless carried
5318          // explicitly — the same invariant repaired in `rename_node_internal`
5319          // for arXiv/html_feedback#6873. This completes node-box carrying
5320          // across all four node-creating ops (open / wrap / rename / clone).
5321          // Latent today — the current `append_clone` sites (MathFork, contacts)
5322          // don't re-read a clone's box for size, and box-derived outputs
5323          // (`tex=`, ids) ride across as copied attributes — but it keeps a
5324          // future size-dependent afterClose on cloned SVG content from
5325          // silently misfiring the way the foreignObject y-flip transform did.
5326          if let Some(childbox) = self.get_node_box(&child) {
5327            self.set_node_box(&new, childbox);
5328          }
5329          self.after_open(&mut new)?;
5330          self.append_clone_aux(&mut new, child.get_child_nodes(), id_map)?;
5331          self.after_close(&mut new)?;
5332        },
5333        Some(NodeType::TextNode) => node.append_text(&child.get_content())?,
5334        Some(NodeType::CommentNode) => {
5335          // Skip XML comments during cloning (Perl also skips them in most contexts)
5336        },
5337        other => {
5338          emit_warn(
5339            "internal",
5340            "document",
5341            &format!("append_clone_aux: skipping unsupported {other:?} node type"),
5342          );
5343        },
5344      };
5345    }
5346    Ok(())
5347  }
5348
5349  //**********************************************************************
5350  // Wrapping & Unwrapping nodes by another element.
5351
5352  /// Wrap `nodes` with an element named `qname`, making the new element replace
5353  /// the first `node`, and all `nodes` becomes the child of the new node.
5354  /// \[this makes most sense if `nodes` are a sequence of siblings\]
5355  ///
5356  /// Returns `None` if `qname` isn't allowed in the parent, or if `nodes`
5357  /// aren't allowed in `qname`, otherwise the newly created `qname` — so a
5358  /// caller must treat "wrapped" as a request the model may decline, not as a
5359  /// guarantee. `None` also covers a first node with no parent (already
5360  /// detached, or the root): there is nothing to wrap it in place of. Witness
5361  /// 1804.09736.
5362  ///
5363  /// The wrapper inherits the parent's font and box, so wrapping does not
5364  /// change how the enclosed material renders. [`unwrap_nodes`](Self::unwrap_nodes)
5365  /// is the inverse.
5366  pub fn wrap_nodes(&mut self, qname: &str, nodes: Vec<Node>) -> Result<Option<Node>> {
5367    if nodes.is_empty() {
5368      return Ok(None);
5369    }
5370    let first_node = &nodes[0];
5371    // Can't wrap a node that has no parent (already detached / is the root) —
5372    // return None like the other "can't wrap here" paths. Witness: 1804.09736.
5373    let Some(mut parent) = first_node.get_parent() else {
5374      return Ok(None);
5375    };
5376    let (ns, tag) = model::decode_qname(qname)?;
5377    let mut new = self.open_element_internal(&mut parent, ns, &tag)?;
5378    self.after_open(&mut new)?;
5379    parent.replace_child_node(new.clone(), first_node.clone())?;
5380
5381    self.copy_node_font(&parent, &mut new)?;
5382
5383    if let Some(tbox) = self.get_node_box(&parent) {
5384      self.set_node_box(&new, tbox);
5385    }
5386    for mut node in nodes.into_iter() {
5387      node.unlink();
5388      new.add_child(&mut node)?;
5389    }
5390    self.after_close(&mut new)?;
5391    Ok(Some(new))
5392  }
5393
5394  /// Unwrap the children of $node, by replacing $node by its children.
5395  pub fn unwrap_nodes(&mut self, node: Node) -> Result<()> {
5396    let children = node.get_child_nodes();
5397    self.replace_node(node, children)
5398  }
5399
5400  /// Replace `node` by `nodes` (presumably descendants of some kind?)
5401  // DG: Don't return the replaced `node`, as it is groudns for memory management trouble
5402  //     with the low-level libxml layer. I've encountered segfaults here.
5403  pub fn replace_node(&mut self, mut node: Node, with: Vec<Node>) -> Result<()> {
5404    if let Some(_parent) = node.get_parent() {
5405      // libxml2's xmlAddNextSibling merges consecutive text nodes: when both
5406      // the reference sibling and the new node are TextNode, it appends the
5407      // new node's content to the reference node and frees the new node. The
5408      // Rust wrapper doesn't surface the merged result, so naively advancing
5409      // `c0_opt` to `with_node` would capture a pointer to freed memory,
5410      // producing silent data loss for the third+ insertion and eventually
5411      // a libxml2 SIGSEGV when the dangling pointer is re-traversed (e.g.
5412      // during a later XPath evaluation). Detect the text-text case and
5413      // coalesce in-place instead.
5414      let mut c0_opt: Option<Node> = None;
5415      for mut with_node in with.into_iter() {
5416        with_node.unlink();
5417        let is_text = with_node.get_type() == Some(NodeType::TextNode);
5418        if let Some(mut c0) = c0_opt {
5419          let c0_is_text = c0.get_type() == Some(NodeType::TextNode);
5420          if is_text && c0_is_text {
5421            let existing = c0.get_content();
5422            let added = with_node.get_content();
5423            c0.set_content(&format!("{existing}{added}"))?;
5424            // with_node is still a standalone (unlinked) text node; drop it.
5425            c0_opt = Some(c0);
5426            continue;
5427          }
5428          c0.add_next_sibling(&mut with_node)?;
5429        } else {
5430          // first node, swap in
5431          node.add_next_sibling(&mut with_node)?;
5432        }
5433        c0_opt = Some(with_node);
5434      }
5435      self.remove_node(node);
5436    }
5437    Ok(())
5438  }
5439
5440  /// Rename an element to `newname`, returning the new node.
5441  ///
5442  /// Not an in-place rename: a fresh element is opened next to the original,
5443  /// the attributes and (when `reinsert`) the children are carried over, and
5444  /// the original is removed. Perl went this way "initially since
5445  /// `$node->setNodeName` was broken in XML::LibXML 1.58", and kept it because
5446  /// building the replacement through the normal open path is what runs the
5447  /// model's checks and `afterOpen` hooks for the new tag — a raw rename would
5448  /// leave an element the schema never vetted.
5449  ///
5450  /// [`rename_node_qsym`](Self::rename_node_qsym) is the same for an already
5451  /// interned name.
5452  pub fn rename_node(&mut self, node: Node, newname: &str, reinsert: bool) -> Result<Node> {
5453    let (ns, tag) = model::decode_qname(newname)?;
5454    let newsym = arena::pin(newname);
5455    self.rename_node_internal(node, newsym, ns, tag, reinsert)
5456  }
5457  pub fn rename_node_qsym(&mut self, node: Node, newsym: SymStr, reinsert: bool) -> Result<Node> {
5458    let (ns, tag) = model::decode_qname_sym(newsym)?;
5459    self.rename_node_internal(node, newsym, ns, tag, reinsert)
5460  }
5461  fn rename_node_internal(
5462    &mut self,
5463    mut node: Node,
5464    newname: SymStr,
5465    ns: Option<String>,
5466    tag: String,
5467    reinsert: bool,
5468  ) -> Result<Node> {
5469    let mut parent = node
5470      .get_parent()
5471      .expect("rename should never be called on an orphan or root node.");
5472    let mut new = self.open_element_internal(&mut parent, ns, &tag)?;
5473    // Move to the position AFTER node
5474    node.add_next_sibling(&mut new)?;
5475    // Copy ALL attributes from `node` to `newnode`
5476    let mut id = None;
5477    for (key, value) in node.get_attributes() {
5478      // `get_attributes()` returns the `xml:id` attribute under its LOCAL name
5479      // `"id"` (it lives in the XML namespace), NOT the prefixed `"xml:id"`.
5480      // Capture it here so it can be re-registered on `new` AFTER `remove_node`
5481      // unrecords it below — and re-set it through `Document::set_attribute`
5482      // (which routes "id"/"xml:id" through `record_id_with_node` + the XML
5483      // namespace), rather than the raw `Node::set_attribute` copy used for
5484      // ordinary attributes (that would drop the namespace AND the idstore
5485      // registration). Missing this stranded the equation refnum id across
5486      // `rearrange_lone_ams_aligned`'s equation→equationgroup rename, leaving
5487      // the group with a generic paragraph id and dangling intra-math XMRefs
5488      // (witness 2311.01600; see docs/parity/diagnostics/EXPECTED_ID_XMREF_DESIGN_2026-06-08.md).
5489      if key == "xml:id" || key == "id" {
5490        id = Some(value);
5491        continue;
5492      }
5493      let can_have = model::can_have_attribute(newname, arena::pin(&key));
5494      if can_have {
5495        new.set_attribute(&key, &value)?;
5496      }
5497    }
5498    // AND move all content from `node` to `newnode`
5499    if !reinsert {
5500      for mut child in node.get_child_nodes() {
5501        child.unbind();
5502        new.add_child(&mut child)?;
5503      }
5504    } else {
5505      std::mem::swap(&mut self.node, &mut new);
5506      for mut child in node.get_child_nodes() {
5507        child.unbind();
5508        if child.get_type() == Some(NodeType::TextNode) {
5509          self.open_text_internal(&child.get_content())?;
5510          self.close_text_internal()?;
5511        } else {
5512          let child_qname = get_node_qname(&child);
5513          let mut point = self.find_insertion_point_qsym(child_qname, None)?;
5514          point.add_child(&mut child)?;
5515        }
5516      }
5517      std::mem::swap(&mut self.node, &mut new);
5518    }
5519    // Carry the node box across the rename. Perl's `renameNode` copies ALL of
5520    // `$node`'s attributes to `$new`, and the box is recorded as the internal
5521    // `_box` attribute, so `afterClose`'s `getNodeBox($new)` still finds it. Our
5522    // node box lives in a side map keyed by node identity — the fresh `new` node
5523    // would otherwise be box-less, so a size-dependent afterClose handler
5524    // misfires. Concretely: `insert_block` renames a `_CaptureBlock_` (which
5525    // carries the block's box) to `svg:foreignObject`; without the box, the fo's
5526    // afterClose (`tex_box.rs`, Perl `TeX_Box.pool.ltxml` L407-423) can't read a
5527    // size and skips the y-flip `transform="matrix(1 0 0 -1 0 h)"`, so a
5528    // `tabular` inside a `tcolorbox` `enhanced` skin renders upside down in the
5529    // TeX-y-up SVG group (arXiv/html_feedback#6873, paper 2601.13118 Table 2).
5530    if let Some(nodebox) = self.get_node_box(&node) {
5531      self.set_node_box(&new, nodebox);
5532    }
5533    // THEN call afterOpen... ?
5534    //   It would normally be called before children added,
5535    //   but how can we know if we're duplicated auto-added stuff?
5536    self.after_open(&mut new)?;
5537    self.after_close(&mut new)?;
5538    // Finally, remove the old node
5539    self.remove_node(node);
5540
5541    // and FINALLY, we can register the new node under the id.
5542    // `Document::set_attribute("xml:id", …)` routes through
5543    // `record_id_with_node` (idstore registration + dedup) and sets the
5544    // correctly-namespaced `xml:id` attribute. Only set it when the new qname
5545    // can carry an id (mirrors the per-attribute `can_have_attribute` gate).
5546    if let Some(id) = id
5547      && model::can_have_attribute(newname, arena::pin("xml:id"))
5548    {
5549      self.set_attribute(&mut new, "xml:id", &id)?;
5550    }
5551
5552    Ok(new)
5553  }
5554
5555  /// Whitespace inside an explicit TYPEWRITER text wrapper is verbatim
5556  /// CONTENT (code indentation, measured by the sizing pass) — the p-edge
5557  /// trim recursion must not descend into it. During construction the
5558  /// public `font` attribute may not be written yet, so also decode the
5559  /// internal `_font`. `ltx:verbatim` itself is EXEMPT: Perl's edge trim
5560  /// descends into an inline `\verb` at a paragraph edge and trims its
5561  /// leading space (tokenize/verb.t), and we keep that parity.
5562  fn is_typewriter_wrapper(&self, node: &Node) -> bool {
5563    if node.get_name() == "verbatim" {
5564      return false;
5565    }
5566    if node
5567      .get_attribute("font")
5568      .is_some_and(|f| f.contains("typewriter"))
5569    {
5570      return true;
5571    }
5572    node
5573      .get_attribute("_font")
5574      .and_then(|id| self.decode_font(&id))
5575      .is_some_and(|f| f.family.as_deref() == Some("typewriter"))
5576  }
5577
5578  pub fn trim_node_whitespace(&mut self, node: &Node) -> Result<()> {
5579    // A paragraph in a typewriter CONTEXT keeps its whitespace: verbatim
5580    // content is line-mapped into ltx:p's (fancyvrb et al.), where leading
5581    // spaces ARE the code indentation (2605.00468 JSON schemas flush-left)
5582    // and a space-only line is real content. Keyed on the PARENT's font
5583    // context — NOT on the node's own computed font, which is stamped from
5584    // whichever box auto-opened it (a prose paragraph STARTING with inline
5585    // \verb reads as typewriter and would wrongly keep its trailing prose
5586    // whitespace; tokenize/verb.t). Perl's own {verbatim} lands in
5587    // ltx:verbatim (no trim hook) so it never faces this; the raw-fancyvrb
5588    // constructs that do cannot be converted by Perl at all (fvextra
5589    // breaklines exceeds 7 min on a 6-line file) — surpass-Perl scope.
5590    if let Some(parent) = node.get_parent()
5591      && self.get_node_font(&parent).family.as_deref() == Some("typewriter")
5592    {
5593      return Ok(());
5594    }
5595    self.trim_node_left_whitespace(node)?;
5596    self.trim_node_right_whitespace(node)?;
5597    Ok(())
5598  }
5599
5600  fn trim_node_left_whitespace(&self, node: &Node) -> Result<()> {
5601    if let Some(mut first_child) = node.get_first_child() {
5602      match first_child.get_type() {
5603        Some(NodeType::TextNode) => {
5604          let content = first_child.get_content();
5605          // Perl: s/^ +// — only trim ASCII spaces, preserve unicode spaces (nbsp, em-space, etc.)
5606          let trimmed_content = content.trim_start_matches(' ');
5607          if !content.is_empty() && (trimmed_content != content) {
5608            first_child.set_content(trimmed_content)?;
5609          }
5610        },
5611        Some(NodeType::ElementNode) if !self.is_typewriter_wrapper(&first_child) => {
5612          self.trim_node_left_whitespace(&first_child)?
5613        },
5614        _ => {},
5615      };
5616    }
5617    Ok(())
5618  }
5619
5620  fn trim_node_right_whitespace(&self, node: &Node) -> Result<()> {
5621    // Skip trailing empty <text> font wrapper elements to find the real last content.
5622    // These are artifacts of font change tracking during alignment absorption.
5623    let mut candidate = node.get_last_child();
5624    while let Some(ref child) = candidate {
5625      if child.get_type() == Some(NodeType::ElementNode)
5626        && child.get_name() == "text"
5627        && child.get_first_child().is_none()
5628        && child.has_attribute("_noautoclose")
5629      {
5630        candidate = child.get_prev_sibling();
5631      } else {
5632        break;
5633      }
5634    }
5635    if let Some(mut last_child) = candidate {
5636      match last_child.get_type() {
5637        Some(NodeType::TextNode) => {
5638          let content = last_child.get_content();
5639          // Perl: s/\s+$// — but we can't trim all Unicode whitespace because some
5640          // tests have significant thin spaces (U+2009) from DimensionToSpaces.
5641          // Trim: ASCII whitespace, nbsp (U+00A0), em-space (U+2003), en-space (U+2002).
5642          let trimmed_content = content.trim_end_matches(|c: char| {
5643            c.is_ascii_whitespace() || c == '\u{00A0}' || c == '\u{2003}' || c == '\u{2002}'
5644          });
5645          if !content.is_empty() && (trimmed_content != content) {
5646            if trimmed_content.is_empty() {
5647              // Remove the entirely-whitespace text node
5648              last_child.unlink();
5649            } else {
5650              last_child.set_content(trimmed_content)?;
5651            }
5652          }
5653        },
5654        Some(NodeType::ElementNode) if !self.is_typewriter_wrapper(&last_child) => {
5655          self.trim_node_right_whitespace(&last_child)?
5656        },
5657        _ => {},
5658      };
5659    }
5660    Ok(())
5661  }
5662
5663  pub fn add_resource(&mut self, resource: Resource) -> Result<()> {
5664    // let savenode_opt = self.float_to_element("ltx:resource", false);
5665    let savenode_opt = None;
5666    let mut attrib: HashMap<String, String> = HashMap::default();
5667    attrib.insert(s!("src"), resource.name);
5668    attrib.insert(s!("type"), resource.mimetype);
5669    attrib.insert(s!("media"), resource.media);
5670    let content_box = Digested::from(Tbox {
5671      text: arena::pin(resource.content),
5672      ..Tbox::default()
5673    });
5674    self.insert_element("ltx:resource", vec![&content_box], Some(attrib))?;
5675    if let Some(savenode) = savenode_opt {
5676      self.set_node(&savenode);
5677    }
5678    Ok(())
5679  }
5680
5681  pub fn process_pending_resources(&mut self) -> Result<()> {
5682    let resources: Vec<Resource> = state::take_pending_resources();
5683    for resource in resources {
5684      self.add_resource(resource)?;
5685    }
5686    state::reset_pending_resources();
5687    Ok(())
5688  }
5689
5690  pub fn make_error(&mut self, error_class: &str, content: &str) -> Result<()> {
5691    let savenode_opt = if !self.is_openable("ltx:ERROR") {
5692      self.float_to_element("ltx:ERROR", false)?
5693    } else {
5694      None
5695    };
5696    self.open_element("ltx:ERROR", Some(string_map!("class"=>error_class)), None)?;
5697    // Perl `Document.pm:makeError` L1346: `openText_internal($self,
5698    // ToString($content))`. Drops the failing token name (`\foo`,
5699    // `\bar`, …) as visible text inside the ERROR element so the
5700    // HTML5 `<span class="ltx_ERROR ...">` is not empty. Without
5701    // this, the user sees a zero-width invisible span where the
5702    // problem source should be.
5703    if !content.is_empty() {
5704      self.open_text_internal(content)?;
5705    }
5706    self.close_element("ltx:ERROR")?;
5707    if let Some(savenode) = savenode_opt {
5708      self.set_node(&savenode);
5709    }
5710    Ok(())
5711  }
5712
5713  // The following "floatTo" operations find an appropriate point
5714  // within the document tree preceding the current insertion point.
5715  // They return undef (& issue a warning) if such a point cannot be found.
5716  // Otherwise, they move the current insertion point to the appropriate node,
5717  // and return the previous insertion point.
5718  // After you make whatever changes (insertions or whatever) to the tree,
5719  // you should do
5720  //   document.set_node(savenode)
5721  // to reset the insertion point to where it had been.
5722
5723  /// Find a node in the document that can contain an element `qname`
5724  pub fn float_to_element(&mut self, qname: &str, closeifpossible: bool) -> Result<Option<Node>> {
5725    let mut candidates: VecDeque<Node> = VecDeque::from(self.get_insertion_candidates(&self.node));
5726    let mut closeable = true;
5727    // If the current node can contain already, we're fine right here - just return
5728    if !candidates.is_empty() && can_contain(&candidates[0], qname) {
5729      // Edge case: Don't resume at a text node, if it is current.
5730      // Don't append more to it after other insertions.
5731      if self.node.get_type() == Some(NodeType::TextNode) {
5732        self.set_node(&candidates[0]);
5733      }
5734      return Ok(candidates.pop_front());
5735    }
5736    while !candidates.is_empty() && !can_contain(&candidates[0], qname) {
5737      if closeable {
5738        closeable = can_auto_close(&candidates[0]);
5739      }
5740      candidates.pop_front();
5741    }
5742    match candidates.pop_front() {
5743      Some(n) => {
5744        if closeifpossible && closeable {
5745          self.close_to_node(&n, false)?;
5746        } else {
5747          let savenode = self.node.clone();
5748          self.set_node(&n);
5749          // Debug!("Floating from " . Stringify($savenode) . " to " . Stringify($n) . " for
5750          // $qname")   if ($$savenode ne $$n) && $LaTeXML::DEBUG{document};
5751          return Ok(Some(savenode));
5752        }
5753      },
5754      _ => {
5755        if can_contain_node_somehow(&self.node, qname).is_none() {
5756          Warn!(
5757            "malformed",
5758            qname,
5759            s!("No open node can contain element '{}'", qname)
5760          );
5761          // self.get_insertion_context())
5762        }
5763      },
5764    }
5765    Ok(None)
5766  }
5767
5768  // find a node that can accept a label.
5769  // A bit more than just whether the element can have the attribute, but
5770  // whether it has an id (and ideally either a refnum or title)
5771  pub fn float_to_label(&mut self) -> Option<Node> {
5772    let key = "labels";
5773    // Perl: start from lastChild of current node if it's an element
5774    let start = if self.node.get_type() == Some(NodeType::ElementNode) {
5775      self
5776        .node
5777        .get_last_child()
5778        .unwrap_or_else(|| self.node.clone())
5779    } else {
5780      self.node.clone()
5781    };
5782    let ancestors: Vec<Node> = self
5783      .get_insertion_candidates(&start)
5784      .into_iter()
5785      .filter(|node| node.get_type() == Some(NodeType::ElementNode))
5786      .collect();
5787    let mut candidates: VecDeque<&Node> = ancestors.iter().collect();
5788    // Should we only accept a node that already has an id, or should we create an id?
5789    let mut node_opt: Option<Cow<Node>> = None;
5790    while let Some(candidate) = candidates.pop_front() {
5791      if can_node_have_attribute(candidate, key) && candidate.has_attribute_ns("id", XML_NS) {
5792        node_opt = Some(Cow::Borrowed(candidate));
5793        break;
5794      }
5795    }
5796
5797    if node_opt.is_none() {
5798      // No appropriate ancestor?
5799      let sib: Option<Node> = match ancestors.first() {
5800        Some(n) => n.get_last_child(),
5801        None => None,
5802      };
5803      if let Some(sibling) = sib {
5804        if can_node_have_attribute(&sibling, key) && sibling.has_attribute_ns("id", XML_NS) {
5805          node_opt = Some(Cow::Owned(sibling));
5806        } else if !ancestors.is_empty() {
5807          // just take root element?
5808          node_opt = Some(Cow::Borrowed(ancestors.last().as_ref().unwrap()));
5809        }
5810      } else if !ancestors.is_empty() {
5811        // just take root element?
5812        node_opt = Some(Cow::Borrowed(ancestors.last().as_ref().unwrap()));
5813      }
5814    }
5815    if let Some(node) = node_opt {
5816      let savenode = self.node.clone();
5817      self.set_node(&node);
5818      Some(savenode)
5819    } else {
5820      let message = s!("No open node with an xml:id can get attribute {:?}", key);
5821      Warn!("malformed", key, message);
5822      //  $self->getInsertionContext());
5823      None
5824    }
5825  }
5826
5827  pub fn set_box_to_absorb(&mut self, arg: Option<Digested>) {
5828    self.localized_boxes.push(self.box_to_absorb.take());
5829    self
5830      .localized_box_locators
5831      .push(self.current_box_locator.take());
5832    self.box_to_absorb = arg;
5833    // Capture the locator now, while the box's RefCell is unborrowed — the
5834    // source-map stamping (`open_element`) reads this Copy value instead of
5835    // re-borrowing the box mid-`be_absorbed`. Gated so the normal path is free.
5836    self.current_box_locator = if state::source_map_enabled() {
5837      self.box_to_absorb.as_ref().and_then(|b| b.get_locator())
5838    } else {
5839      None
5840    };
5841  }
5842  pub fn expire_box_to_absorb(&mut self) {
5843    self.box_to_absorb = self.localized_boxes.pop().unwrap();
5844    self.current_box_locator = self.localized_box_locators.pop().unwrap_or(None);
5845  }
5846
5847  /// token-locators: directly set the locator used to stamp the NEXT opened
5848  /// element, without touching the `box_to_absorb` stack. The alignment absorb
5849  /// uses this to give each `tabular`/`tr`/`td` its own (table/row/cell) span,
5850  /// since those elements are opened *before* their content's `box_to_absorb`
5851  /// is set. Transient: each cell overwrites it and the enclosing
5852  /// `expire_box_to_absorb` (the Alignment absorb frame) restores the prior
5853  /// value. See docs/performance/SOURCE_PROVENANCE.md §3.1.3.
5854  #[cfg(feature = "token-locators")]
5855  pub fn set_current_box_locator(&mut self, loc: Option<Locator>) {
5856    self.current_box_locator = loc;
5857  }
5858
5859  /// Resolve a rewrite label: this document's own labels first, then the
5860  /// shared document-wide map a streaming fragment carries
5861  /// (`rewrite_labels_shared`). The two-level lookup replaces copying the
5862  /// whole label index into every fragment — see the field docs.
5863  pub fn lookup_rewrite_label(&self, key: &str) -> Option<String> {
5864    self
5865      .rewrite_labels
5866      .get(key)
5867      .or_else(|| {
5868        self
5869          .rewrite_labels_shared
5870          .as_ref()
5871          .and_then(|shared| shared.get(key))
5872      })
5873      .cloned()
5874  }
5875
5876  pub fn load_labels_for_rewrite(&mut self) -> Result<()> {
5877    for mut node in self.findnodes("//*[@labels]", None) {
5878      if let Some(labels) = node.get_attribute("labels") {
5879        // A labelled node MUST carry an xml:id so `\ref` can resolve to it.
5880        // Normally the `Tag('ltx:*', afterClose:late)` GenerateID hook
5881        // (latex_constructs.rs) stamps one, but it does not reach every node
5882        // — notably the <ltx:document> root, which receives a label when a
5883        // bare `\label{…}` appears with no enclosing id'd sectioning (e.g.
5884        // `\input{abs}` then `\label{sec:intro}` before any \section; witness
5885        // 1703.09326). Perl handles this by giving the root an xml:id — its
5886        // output is `<document … labels="LABEL:sec:intro" xml:id="id1">` —
5887        // NOT by erroring. Match Perl: generate an id here when one is
5888        // missing, exactly as Perl's GenerateID does (an id-less root yields
5889        // "id1" from `generate_id`'s empty-prefix→"id", no-ancestor path).
5890        let id = match node.get_attribute("id") {
5891          Some(id) => Some(id),
5892          None => {
5893            self.generate_id(&mut node, "")?;
5894            node.get_attribute_ns("id", XML_NS)
5895          },
5896        };
5897        if let Some(id) = id {
5898          for label in labels.split_whitespace() {
5899            self.rewrite_labels.insert(label.to_string(), id.clone());
5900          }
5901        }
5902        // If generate_id still couldn't assign one (a node the model forbids
5903        // an xml:id on), the label is simply unresolvable — drop it silently
5904        // (Perl does not error here either).
5905      }
5906    }
5907    Ok(())
5908  }
5909
5910  fn set_local_font(&mut self, arg: Rc<Font>) { self.localized_fonts.push(arg); }
5911  fn get_local_font(&self) -> Option<Rc<Font>> { self.localized_fonts.last().cloned() }
5912  fn expire_local_font(&mut self) { self.localized_fonts.pop(); }
5913
5914  //**********************************************************************
5915  /// This function computes an xml:id for a node, if it hasn't already got one.
5916  /// It is suitable for use in Tag afterOpen as
5917  ///  `Tag('ltx:para',afterOpen=>sub { GenerateID(@_,'p'); });`
5918  /// It generates an id of the form `<parentid>.<prefix><number>`
5919  /// The parent node (the one with `ID=<parentid>`) also maintains a counter
5920  /// stored in an attribute `_ID_counter_<prefix>` recording the last used
5921  /// `number` for `prefix` amongst its descendents.
5922  pub fn generate_id(&mut self, node: &mut Node, mut prefix: &str) -> Result<()> {
5923    // If node doesn't already have an id, and can
5924    // but isn't a _Capture_ node (which ultimately should disappear)
5925    let qname = get_node_qname(node);
5926    if !node.has_attribute_ns("id", XML_NS)
5927      && model::can_have_attribute(qname, pin!("xml:id"))
5928      && (qname != pin!("ltx:_Capture_"))
5929    {
5930      // Nearest ancestor element carrying @xml:id — a direct parent-chain
5931      // walk, equivalent to the previous `ancestor::*[@xml:id][1]` findnode
5932      // but without a per-call XPath parse+eval (generate_id runs once per
5933      // id-lacking node during finalize; 2026-08-23 audit R5).
5934      let mut ancestor = {
5935        let mut cur = node.get_parent();
5936        let mut found = None;
5937        while let Some(p) = cur {
5938          if p.get_type() == Some(NodeType::ElementNode) && p.has_attribute_ns("id", XML_NS) {
5939            found = Some(p);
5940            break;
5941          }
5942          cur = p.get_parent();
5943        }
5944        found.unwrap_or_else(|| self.get_document().get_root_element().unwrap())
5945      };
5946      //// Old versions don't like ancestor.getAttribute('xml:id');
5947      let ancestor_id = ancestor.get_attribute_ns("id", XML_NS);
5948      // If we've got no ancestor_id, then we've got no ancestor (no document yet!),
5949      // or ancestor IS the root element (but without an id);
5950      // If we also have no prefix, we'll end up with an illegal id (just digits)!!!
5951      // We'll use "id" for an id prefix; this will work whether or not we have an ancestor.
5952      if prefix.is_empty() && ancestor_id.is_none() {
5953        prefix = "id";
5954      }
5955
5956      // Perl `Package.pm:939` (`'_ID_counter_' . ($prefix ? $prefix . '_' : '')`)
5957      // — empty prefix uses `_ID_counter_` with a single trailing underscore,
5958      // not `_ID_counter__`. Matters for interop with code that reads the
5959      // attribute by exact name (e.g. `Base_XMath.pool.ltxml:940` reads
5960      // `_ID_counter_` for the empty-prefix counter).
5961      let ctrkey = if prefix.is_empty() {
5962        s!("_ID_counter_")
5963      } else {
5964        s!("_ID_counter_") + prefix + "_"
5965      };
5966      let a_ctr = ancestor.get_attribute(&ctrkey).unwrap_or_else(|| s!("0"));
5967
5968      let ctr_int = 1 + a_ctr.parse::<u32>().unwrap_or(0);
5969      let ctr = ctr_int.to_string();
5970
5971      let id = match ancestor_id {
5972        Some(aid) => aid + ".",
5973        None => String::new(),
5974      } + prefix
5975        + &ctr;
5976
5977      ancestor.set_attribute(&ctrkey, &ctr)?;
5978      self.set_attribute(node, "xml:id", &id)?;
5979    }
5980    Ok(())
5981  }
5982
5983  //%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
5984  // Finally, another set of surgery methods
5985  // These take an array representation of the XML Tree to append
5986  //   [tagname,{attributes..}, children]
5987  // THESE SHOULD BE PART OF A COMMON BASE CLASS; DUPLICATED IN Post::Document
5988  //%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
5989
5990  pub fn replace_tree(&mut self, new: Node, old: Node) -> Result<Option<Node>> {
5991    match old.get_parent() {
5992      Some(mut parent) => {
5993        let mut following = VecDeque::new(); // Collect the matching and following nodes
5994        while let Some(mut sib) = parent.get_last_child() {
5995          if sib == old {
5996            break;
5997          }
5998          // parent.remove_child(sib); // We're putting these back, in a moment!
5999          sib.unlink();
6000          following.push_front(sib);
6001        }
6002        // Unrecord old's ids BEFORE the copy, so the copy's freshly
6003        // recorded entries (append_tree → open_element_at) survive — the
6004        // copy typically re-uses the same id strings.
6005        self.unrecord_node_ids(&old);
6006        // COPY FIRST, detach `old` after: `new` may sit INSIDE `old`
6007        // (parse_single's single-node shortcut returns an original child;
6008        // tex_box's foreignObject cleanup replaces a node with its own
6009        // grandchild), so `old` must stay intact while append_tree
6010        // serializes the copy. With `following` detached, the copy lands in
6011        // old's slot either way.
6012        self.append_tree(&mut parent, vec![new])?;
6013        let inserted = parent.get_last_child();
6014        // Keep the insertion point out of the discarded subtree (the
6015        // `chopped` half of remove_node; its id bookkeeping happened above).
6016        let mut cursor = Some(self.node.clone());
6017        while let Some(cur) = cursor {
6018          if cur == old {
6019            self.set_node(&parent);
6020            break;
6021          }
6022          cursor = cur.get_parent();
6023        }
6024        {
6025          let mut old = old;
6026          old.unlink();
6027        }
6028        for mut child in following {
6029          parent.add_child(&mut child)?; // No need for clone
6030        }
6031        Ok(inserted)
6032      },
6033      _ => Ok(None),
6034    }
6035  }
6036
6037  /// `replace_tree` for a caller that owns BOTH trees as garbage-after-copy
6038  /// (the math parser's rebuild sites): the replacement is copied into
6039  /// place exactly as `replace_tree` does (Perl appendTree parity —
6040  /// elements are re-created, sources abandoned), and then the sources are
6041  /// FREED: `old`'s subtree, plus `new`'s detached root when `new` is a
6042  /// standalone built tree rather than a node inside `old`. Without the
6043  /// frees every replaced formula leaks its pre-parse tree AND the built
6044  /// parse tree (see `discard_subtree`).
6045  ///
6046  /// On the `None` return (old had no parent) nothing was copied and
6047  /// NOTHING is freed — the caller keeps using `new` as-is.
6048  pub fn replace_tree_free(&mut self, new: Node, old: Node) -> Result<Option<Node>> {
6049    // Resolve new's root BEFORE any freeing (walking afterwards would read
6050    // freed memory). A chain ending at a Document node means `new` is
6051    // inside a live tree — either inside `old` (freed below with it) or
6052    // elsewhere (not ours to free).
6053    let new_root = xml::detached_root(&new);
6054    let inserted = self.replace_tree(new, old.clone())?;
6055    if inserted.is_some() {
6056      self.discard_subtree(old);
6057      if let Some(root) = new_root {
6058        // Standalone source tree; disjoint from old's subtree by
6059        // construction (a detached root has no parent, every node inside
6060        // old's subtree has one).
6061        self.discard_subtree(root);
6062      }
6063    }
6064    Ok(inserted)
6065  }
6066
6067  pub fn append_tree(&mut self, node: &mut Node, data: Vec<Node>) -> Result<()> {
6068    for child in data {
6069      match child.get_type() {
6070        Some(NodeType::ElementNode) => {
6071          let mut attributes: HashMap<String, String> =
6072            child.get_attributes().into_iter().collect();
6073          // Perl appendTree: REMOVE xml:id from source node before re-creation.
6074          // This prevents duplicate ID registration. The ID will be re-registered
6075          // by open_element_at when the new node is created with the same ID.
6076          if let Some(xmlid) = child.get_attribute_ns("id", XML_NS) {
6077            attributes
6078              .entry("xml:id".to_string())
6079              .or_insert_with(|| xmlid.clone());
6080            // Unrecord before re-creation (Perl: $child->removeAttribute('xml:id') + unRecordID)
6081            self.unrecord_id(&xmlid);
6082          }
6083
6084          // `get_FOREIGN_node_qname`: `data` may be a tree parsed elsewhere
6085          // (`Document::insert_xml`), whose elements can sit in a DEFAULT
6086          // namespace that is not ours — `<p xmlns="…/1999/xhtml">`. Only here is
6087          // that shape possible, and only here do we pay to resolve it.
6088          let tag_sym = model::get_foreign_node_qname(&child);
6089          let tag = arena::to_string(tag_sym);
6090          let mut new = self.open_element_at(node, &tag, Some(attributes), None)?;
6091          self.append_tree(&mut new, child.get_child_nodes())?;
6092          self.close_element_at(&mut new)?;
6093        },
6094        Some(NodeType::DocumentFragNode) => {
6095          self.append_tree(node, child.get_child_nodes())?;
6096        },
6097        Some(NodeType::TextNode) => {
6098          node.append_text(&child.get_content())?;
6099        },
6100        other => {
6101          log::debug!("append_tree: unhandled libxml NodeType {other:?}");
6102        },
6103      }
6104    }
6105    Ok(())
6106  }
6107
6108  /// Parse an XML / (X)HTML markup string and splice the resulting subtree into
6109  /// the document at the current insertion point.
6110  ///
6111  /// Named as the markup counterpart to [`Document::insert_element`] (Perl
6112  /// `insertElement`): both insert an already-FINISHED thing at the current point.
6113  /// Deliberately NOT an `absorb*` name — in Perl `absorb` consumes a digested
6114  /// Box and has no `XML::LibXML` branch at all (it would die on a node), so
6115  /// borrowing that verb here would imply a kinship that does not exist.
6116  ///
6117  /// This is the Rust analog of Perl BookML's `\bmlRawHTML` idiom, which composes
6118  /// two mechanisms core never chains itself: `XML::LibXML->parse_string`
6119  /// (`LaTeXML::Common::XML::Parser` `parseChunk`, `Common/XML/Parser.pm:36-39`)
6120  /// and `$document->appendTree`
6121  /// (`Document.pm:2093`, foreign-node branch `:2105-2124`). Both halves already
6122  /// exist here — libxml's parser (a direct `latexml_core` dep, used in
6123  /// `common/relaxng/scan.rs`) and [`Document::append_tree`] — so this is pure
6124  /// glue.
6125  ///
6126  /// The markup must be WELL-FORMED, but need not be a single root: several
6127  /// sibling nodes, or bare text, are accepted as a document fragment
6128  /// (OXIDIZED_DESIGN #66 — Perl's `parseChunk` is single-node only). The parsed
6129  /// nodes are re-created one by one through `append_tree`'s model-aware path, so
6130  /// namespaces declared in the snippet (e.g. xhtml) are preserved and `xml:id`s
6131  /// re-registered. Malformed markup is REJECTED, never salvaged — see
6132  /// [`crate::common::xml::parse_chunk`] for what libxml's recovery mode
6133  /// destroys — and surfaces as a clean `Error:` that inserts nothing, degrading
6134  /// the offending binding rather than aborting the conversion (the
6135  /// runtime-bindings failure-isolation contract).
6136  pub fn insert_xml(&mut self, xml: &str) -> Result<()> {
6137    // The parsed Document OWNS the nodes `append_tree` re-creates from, so it
6138    // must stay alive across the call below (bound here, dropped at fn end).
6139    let parsed = match xml::parse_fragment(xml) {
6140      Ok(doc) => doc,
6141      Err(e) => {
6142        // Quote the offending snippet: a binding may insert markup from several
6143        // places, and the reason alone would not say WHICH one to go fix. Capped
6144        // so a runaway string cannot flood the log with a single message.
6145        const SNIPPET_CAP: usize = 200;
6146        let mut snippet: String = xml.chars().take(SNIPPET_CAP).collect();
6147        if xml.chars().nth(SNIPPET_CAP).is_some() {
6148          snippet.push('…');
6149        }
6150        Error!(
6151          "malformed",
6152          "insertXML",
6153          format!("could not parse XML markup: {e}"),
6154          format!("in: {snippet}")
6155        );
6156        return Ok(());
6157      },
6158    };
6159    if parsed.is_empty() {
6160      Error!(
6161        "malformed",
6162        "insertXML",
6163        "XML markup parsed to an empty document".to_string()
6164      );
6165      return Ok(());
6166    }
6167    // `parsed` stays bound until this returns, so it keeps owning the nodes.
6168    self.insert_nodes(parsed.nodes())
6169  }
6170
6171  /// Splice ALREADY-PARSED nodes into the document at the current insertion
6172  /// point. The shared tail of [`Document::insert_xml`] and of any caller that
6173  /// obtained its nodes some other way (a script that parsed once and inserts
6174  /// repeatedly, or that edited the parsed tree before inserting).
6175  ///
6176  /// The caller must keep whatever owns `nodes` alive across this call: libxml
6177  /// nodes are pointers into their document. [`crate::common::xml::ParsedFragment`]
6178  /// exists to make that ownership explicit rather than a comment.
6179  pub fn insert_nodes(&mut self, nodes: Vec<Node>) -> Result<()> {
6180    let mut point = self.get_node().clone();
6181    self.append_tree(&mut point, nodes)
6182  }
6183}
6184
6185// Auxiliary
6186
6187/// Strip characters that may not appear in XML 1.0 content at all: NUL and
6188/// the other C0 controls except TAB/LF/CR, plus the non-characters
6189/// U+FFFE/U+FFFF. libxml's `CString`-based APIs *panic* on interior NULs
6190/// (node.rs:639), and the rest produce non-well-formed output that downstream
6191/// XML consumers reject. Single shared policy for the document sinks
6192/// (`set_attribute`, `open_text_internal`, `open_math_text_internal`).
6193/// Borrow-free in the (overwhelmingly common) clean case.
6194fn xml_sanitize(value: &str) -> Cow<'_, str> {
6195  fn invalid(c: char) -> bool {
6196    (c < '\u{20}' && c != '\t' && c != '\n' && c != '\r') || c == '\u{FFFE}' || c == '\u{FFFF}'
6197  }
6198  if value.chars().any(invalid) {
6199    Cow::Owned(value.chars().filter(|c| !invalid(*c)).collect())
6200  } else {
6201    Cow::Borrowed(value)
6202  }
6203}
6204
6205fn serialize_string(string: &str) -> String {
6206  // Basic entities
6207  let mut serialized = string.replace('&', "&amp;");
6208  serialized = serialized.replace('>', "&gt;");
6209  serialized = serialized.replace('<', "&lt;");
6210  serialized
6211}
6212
6213fn serialize_attr(string: &str) -> String {
6214  let mut serialized = serialize_string(string);
6215  // And escape any remaining special code points
6216  serialized = serialized.replace('\"', "&quot;");
6217  serialized = serialized.replace('\n', "&#10;");
6218  serialized = serialized.replace('\t', "&#9;");
6219  serialized
6220}
6221
6222pub trait IntoVDQS {
6223  fn into_vdqs(self) -> VecDeque<SymStr>
6224  where Self: Sized;
6225}
6226impl IntoVDQS for SymStr {
6227  fn into_vdqs(self) -> VecDeque<SymStr> {
6228    let mut vdq = VecDeque::new();
6229    vdq.push_front(self);
6230    vdq
6231  }
6232}
6233impl IntoVDQS for &str {
6234  fn into_vdqs(self) -> VecDeque<SymStr> {
6235    let mut vdq = VecDeque::new();
6236    vdq.push_front(arena::pin(self));
6237    vdq
6238  }
6239}
6240
6241impl IntoVDQS for VecDeque<SymStr> {
6242  fn into_vdqs(self) -> VecDeque<SymStr> { self }
6243}
6244
6245// containment checks are package-level (and maybe can be moved in a new submodule?)
6246
6247pub fn can_contain(node: &Node, child: &str) -> bool {
6248  let tag = model::get_node_qname(node);
6249  model::can_contain_sym(tag, arena::pin(child))
6250}
6251
6252pub fn can_contain_node(node: &Node, child: &Node) -> bool {
6253  let tag = model::get_node_qname(node);
6254  let child_tag = model::get_node_qname(child);
6255  model::can_contain_sym(tag, child_tag)
6256}
6257
6258pub fn can_contain_qname(tag: &str, child: &str) -> bool { model::can_contain(tag, child) }
6259
6260pub fn node_can_contain_sym(node: &Node, child: SymStr) -> bool {
6261  let tag = model::get_node_qname(node);
6262  model::can_contain_sym(tag, child)
6263}
6264pub fn can_contain_qsym(tag: SymStr, child: SymStr) -> bool { model::can_contain_sym(tag, child) }
6265
6266/// Can an element with (qualified name) `tag` contain a `childtag` element indirectly?
6267/// That is, by openning some number of autoOpen'able tags?
6268/// And if so, return the tag to open.
6269pub fn can_contain_indirect(tag: SymStr, child: SymStr) -> Option<SymStr> {
6270  // $tag = $model->getNodeQName($tag) if ref $tag;          // In case tag is a
6271  // node. $child = $model->getNodeQName($child) if ref $child;    // In case
6272  // child is a node.
6273  if !state::has_indirect_model() {
6274    let i_model = state::compute_indirect_model();
6275    state::set_indirect_model(i_model);
6276  }
6277  state::get_indirect_model_relationship(tag, child)
6278}
6279
6280pub fn can_contain_node_somehow(node: &Node, child: &str) -> Option<Option<SymStr>> {
6281  let child_sym = arena::pin(child);
6282  sym_can_contain_somehow(model::get_node_qname(node), child_sym)
6283}
6284
6285pub fn can_contain_somehow(tag: &str, child: &str) -> bool {
6286  let tag_sym = arena::pin(tag);
6287  let child_sym = arena::pin(child);
6288  sym_can_contain_somehow(tag_sym, child_sym).is_some()
6289}
6290
6291/// The return type of this method is somewhat artisinal, as we have a three-way semantics:
6292/// - `None`: There is no known structure that allows `child` as a descendant of `tag`
6293/// - `Some(None)`: `child` is directly allowed inside `tag`
6294/// - `Some(Some(inter_tag))`: `child` is allowed inside `inter_tag`, which is allowed in `tag`
6295///
6296/// This could also (maybe more naturally?) be represented with a custom 3-valued enum.
6297/// That said, I think it may be wiser to refactor the method entirely, always requiring a `bool`
6298/// check, followed by an explicit request for the `inner_tag` name, which can be `Option<SymStr>`.
6299pub fn sym_can_contain_somehow(tag: SymStr, child: SymStr) -> Option<Option<SymStr>> {
6300  match model::can_contain_sym(tag, child) {
6301    true => Some(None),
6302    false => can_contain_indirect(tag, child).map(Some),
6303  }
6304}
6305
6306pub fn can_node_have_attribute(node: &Node, attrib: &str) -> bool {
6307  let qname = model::get_node_qname(node);
6308  model::can_have_attribute(qname, arena::pin(attrib))
6309}
6310pub fn can_have_attribute(tag: &str, attrib: &str) -> bool {
6311  model::can_have_attribute(arena::pin(tag), arena::pin(attrib))
6312}
6313pub fn sym_can_have_attribute(tag: SymStr, attrib: SymStr) -> bool {
6314  model::can_have_attribute(tag, attrib)
6315}
6316
6317// Dirty little secrets:
6318//  You can generically allow an element to autoClose using Tag.
6319// OR you can indicate a specific node can autoClose, or forbid it, using
6320// the _autoclose or _noautoclose attributes!
6321pub fn can_auto_close(node: &Node) -> bool {
6322  // text or comments auto close
6323  // otherwise must be element
6324  // without _noautoclose
6325  // and either with _autoclose
6326  // OR it has autoClose set on tag properties
6327  match node.get_type() {
6328    Some(NodeType::TextNode) | Some(NodeType::CommentNode) => true,
6329    Some(NodeType::ElementNode) if !node.has_attribute("_noautoclose") => {
6330      if node.has_attribute("_autoclose") {
6331        true
6332      } else {
6333        state::with_tag_property(get_node_qname(node), |props_opt| {
6334          if let Some(props) = props_opt {
6335            props.auto_close.unwrap_or(false)
6336          } else {
6337            false
6338          }
6339        })
6340      }
6341    },
6342    _ => false,
6343  }
6344}
6345/// Get the node's qualified name in standard form.
6346///
6347/// Ie. using the registered prefix for that namespace.
6348/// NOTE: Reconsider how _Capture_ & _WildCard_ should be integrated!?!
6349/// NOTE: Should Deprecate! (use model)
6350pub fn get_node_qname(node: &Node) -> SymStr { model::get_node_qname(node) }
6351pub fn with_node_qname<R, FnR>(node: &Node, caller: FnR) -> R
6352where FnR: FnOnce(&str) -> R {
6353  model::with_node_qname(node, caller)
6354}