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