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