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