Skip to main content

latexml_post/
document.rs

1//! Post-processing document wrapper with XML/DOM, ID management, and caching.
2//!
3//! Port of `LaTeXML::Post::Document`.
4//! Wraps an `XML::LibXML::Document` (via the `libxml` crate) and provides:
5//! - Namespace management
6//! - ID tracking (idcache, reusable, reserved)
7//! - XPath queries with registered namespaces
8//! - Node manipulation (addNodes, removeNodes, cloneNode, etc.)
9//! - Persistent cache (key-value store)
10
11use std::path::Path;
12
13use libxml::{
14  parser::Parser as XmlParser,
15  tree::{Document, Namespace, Node, NodeType},
16  xpath::Context as XPathContext,
17};
18use regex::Regex;
19use rustc_hash::{FxHashMap as HashMap, FxHashSet};
20use unicode_normalization::UnicodeNormalization;
21
22use crate::radix::radix_alpha;
23
24const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
25
26/// Get the xml:id attribute value from a node.
27/// Handles both namespace-aware and plain attribute access.
28pub fn get_xml_id(node: &Node) -> Option<String> {
29  node
30    .get_attribute_ns("id", XML_NS)
31    .or_else(|| node.get_attribute("xml:id"))
32    .or_else(|| {
33      // Fallback: check properties hash for "id" key
34      let props = node.get_properties();
35      props.get("id").cloned()
36    })
37}
38
39/// Remap a cloned node's `fragid` after its `xml:id` was uniquified.
40///
41/// Port of Perl `Post::Document::cloneNode` (`Post.pm` L1268-1270):
42/// `fragid => substr($newid, length($id) - length($fragid))`. The fragid is
43/// the reference-visible tail of the id (equal to the whole id for a top-level
44/// object), so we take the corresponding tail of the new id. IDs are ASCII, so
45/// byte and character offsets coincide.
46fn remap_fragid(id: &str, new_id: &str, fragid: &str) -> String {
47  let offset = id.len().saturating_sub(fragid.len());
48  new_id.get(offset..).unwrap_or(fragid).to_string()
49}
50
51/// The LaTeXML namespace URI.
52pub const LTX_NSURI: &str = "http://dlmf.nist.gov/LaTeXML";
53
54/// The crate-default leniency (recover, noerror, nowarning) plus
55/// `XML_PARSE_HUGE` — see [`PostDocument::new_from_file`] for why post's
56/// parses must relax libxml2's hard limits.
57fn huge_parse_options() -> libxml::parser::ParserOptions<'static> {
58  libxml::parser::ParserOptions {
59    huge: true,
60    ..Default::default()
61  }
62}
63
64/// True when `node` is an element in the LaTeXML (`ltx:`) namespace.
65///
66/// The namespace lookup wraps a `Namespace`, so callers should gate it behind a
67/// cheaper localname check on the hot path (see [`collect_split_pages`]).
68pub(crate) fn is_ltx(node: &Node) -> bool {
69  node
70    .get_namespace()
71    .map(|ns| ns.get_href() == LTX_NSURI)
72    .unwrap_or(false)
73}
74
75/// Limit-safe pre-order walk collecting every element's `xml:id` and every
76/// `latexml` PI, in document order. Pass the *document* node so PIs preceding
77/// the root element are visited too.
78///
79/// This is the "limit-safe" pattern the post-processing queries share: a
80/// full-document `//X` (or `//X[pred]`) XPath makes libxml2 materialize
81/// `descendant-or-self::node()`, which past 10M nodes overflows the hardcoded
82/// `XPATH_MAX_NODESET_LENGTH`, returns NULL, and — formerly silently — yields an
83/// empty result. A direct DOM walk has no node-set and no such ceiling.
84fn scan_ids_and_pis(node: &Node, ids: &mut Vec<(String, Node)>, pis: &mut Vec<String>) {
85  let mut child = node.get_first_child();
86  while let Some(c) = child {
87    match c.get_type() {
88      Some(NodeType::ElementNode) => {
89        if let Some(id) = get_xml_id(&c) {
90          ids.push((id, c.clone()));
91        }
92        scan_ids_and_pis(&c, ids, pis);
93      },
94      Some(NodeType::PiNode) if c.get_name() == "latexml" => {
95        pis.push(c.get_content());
96      },
97      _ => {},
98    }
99    child = c.get_next_sibling();
100  }
101}
102
103/// One arm of a `--splitat` page union: an `ltx:` element localname plus an
104/// optional disjunctive predicate. Mirrors the arms `make_splitpaths` emits.
105pub(crate) struct SplitArm {
106  /// `ltx:` element localname (e.g. `"section"`, `"index"`).
107  pub(crate) element: String,
108  /// Disjunction of conditions; empty ⇒ the element is unconditionally a page.
109  pub(crate) any_of:  Vec<SplitCond>,
110}
111
112/// A single predicate condition inside a [`SplitArm`] — the only two forms
113/// `make_splitpaths` ever generates.
114pub(crate) enum SplitCond {
115  /// `preceding-sibling::ltx:NAME`
116  PrecedingSibling(String),
117  /// `parent::ltx:NAME`
118  Parent(String),
119}
120
121/// Parse the `make_splitpaths` union (`//ltx:X | //ltx:Y[preceding-sibling::…
122/// or parent::…] | …`) into structured arms. Returns `None` if any arm is
123/// outside this narrow grammar, so the caller can fall back to raw XPath for a
124/// custom `--splitpaths` (which only matters on small documents, where XPath is
125/// limit-safe anyway).
126pub(crate) fn parse_split_union(union_xpath: &str) -> Option<Vec<SplitArm>> {
127  let mut arms = Vec::new();
128  for raw in union_xpath.split('|') {
129    let arm = raw.trim();
130    let rest = arm.strip_prefix("//ltx:")?;
131    let (name, pred) = match rest.split_once('[') {
132      Some((n, p)) => (n.trim(), Some(p.strip_suffix(']')?.trim())),
133      None => (rest.trim(), None),
134    };
135    if name.is_empty()
136      || !name
137        .chars()
138        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
139    {
140      return None;
141    }
142    let mut any_of = Vec::new();
143    if let Some(pred) = pred {
144      for cond in pred.split(" or ") {
145        let cond = cond.trim();
146        if let Some(n) = cond.strip_prefix("preceding-sibling::ltx:") {
147          any_of.push(SplitCond::PrecedingSibling(n.trim().to_string()));
148        } else {
149          // Only `parent::ltx:NAME` remains in the make_splitpaths grammar;
150          // any other predicate is unsupported, so `?` returns `None` here and
151          // the caller falls back to raw XPath.
152          let n = cond.strip_prefix("parent::ltx:")?;
153          any_of.push(SplitCond::Parent(n.trim().to_string()));
154        }
155      }
156    }
157    arms.push(SplitArm {
158      element: name.to_string(),
159      any_of,
160    });
161  }
162  if arms.is_empty() { None } else { Some(arms) }
163}
164
165/// Evaluate one predicate condition against `node` (Rust equivalent of the
166/// `preceding-sibling::ltx:NAME` / `parent::ltx:NAME` XPath primitives).
167pub(crate) fn cond_matches(cond: &SplitCond, node: &Node) -> bool {
168  match cond {
169    SplitCond::PrecedingSibling(name) => {
170      let mut sib = node.get_prev_sibling();
171      while let Some(s) = sib {
172        if s.get_type() == Some(NodeType::ElementNode) && s.get_name() == *name && is_ltx(&s) {
173          return true;
174        }
175        sib = s.get_prev_sibling();
176      }
177      false
178    },
179    SplitCond::Parent(name) => node
180      .get_parent()
181      .map(|p| p.get_name() == *name && is_ltx(&p))
182      .unwrap_or(false),
183  }
184}
185
186/// One arm of a limit-safe whole-document query: an element-name test plus a
187/// conjunction of predicate atoms, all evaluated by traversal.
188#[derive(Debug)]
189struct WalkArm {
190  /// `None` = `*` (any element); `Some(localname)` = an `ltx:` element.
191  name:  Option<String>,
192  /// Disjunctive normal form: OR of AND-groups, so both `[@a and not(@b)]`
193  /// and `[@a or @b]` are expressible. An empty outer vec = no predicate.
194  preds: Vec<Vec<WalkPred>>,
195}
196
197/// The predicate atoms the post-processing queries actually use. Deliberately
198/// a closed set: an unrecognised shape falls back to real XPath rather than
199/// being approximated.
200#[derive(Debug)]
201enum WalkPred {
202  HasAttr(String),
203  NoAttr(String),
204  /// `not(ancestor::ltx:NAME)`
205  NoAncestor(String),
206  /// `not(ltx:NAME)` — no such child element
207  NoChild(String),
208}
209
210/// Parse the whole-document shapes post-processing evaluates on every page and
211/// every document: `//NAME[pred and pred…]`, `*` or `ltx:`-prefixed, unioned
212/// with `|`. Returns `None` for anything outside the grammar, so unusual
213/// queries keep going through libxml2 unchanged.
214///
215/// Why parse at all: as XPath, `//*[@idref]` makes libxml2 materialize a
216/// node-set over EVERY element first, which on a large document both costs
217/// O(document) memory and trips the 10M node-set ceiling — at which point the
218/// evaluation returns NULL and the caller cannot tell "no matches" from
219/// "could not answer". Measured on a 614 MB core XML: six such queries all
220/// failed, post produced a 0-byte HTML, and the run still exited 0. A walk
221/// allocates only the matches and cannot hit the ceiling.
222fn parse_walk_union(union_xpath: &str) -> Option<Vec<WalkArm>> {
223  let mut arms = Vec::new();
224  for raw in union_xpath.split('|') {
225    let arm = raw.trim().strip_prefix("//")?;
226    let (name_part, pred_part) = match arm.split_once('[') {
227      Some((n, p)) => (n.trim(), Some(p.strip_suffix(']')?.trim())),
228      None => (arm.trim(), None),
229    };
230    let name = match name_part {
231      "*" => None,
232      other => Some(other.strip_prefix("ltx:")?.to_string()),
233    };
234    let is_ncname = |n: &str| {
235      !n.is_empty()
236        && n
237          .chars()
238          .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
239    };
240    if let Some(n) = &name
241      && !is_ncname(n)
242    {
243      return None;
244    }
245    let mut preds: Vec<Vec<WalkPred>> = Vec::new();
246    if let Some(pred) = pred_part {
247      // `or` binds looser than `and`, so split on it first: each disjunct is a
248      // conjunction of atoms. Mixed precedence beyond that (parentheses) is
249      // outside the grammar and falls through to XPath.
250      if pred.contains('(') && pred.contains(" or ") && pred.contains(" and ") {
251        return None;
252      }
253      for disjunct in pred.split(" or ") {
254        let mut group = Vec::new();
255        for cond in disjunct.split(" and ") {
256          let cond = cond.trim();
257          let parsed = if let Some(attr) = cond.strip_prefix("@") {
258            is_ncname(attr).then(|| WalkPred::HasAttr(attr.to_string()))
259          } else if let Some(inner) = cond.strip_prefix("not(").and_then(|c| c.strip_suffix(")")) {
260            let inner = inner.trim();
261            if let Some(attr) = inner.strip_prefix("@") {
262              is_ncname(attr).then(|| WalkPred::NoAttr(attr.to_string()))
263            } else if let Some(n) = inner.strip_prefix("ancestor::ltx:") {
264              is_ncname(n).then(|| WalkPred::NoAncestor(n.to_string()))
265            } else if let Some(n) = inner.strip_prefix("ltx:") {
266              is_ncname(n).then(|| WalkPred::NoChild(n.to_string()))
267            } else {
268              None
269            }
270          } else {
271            None
272          };
273          group.push(parsed?);
274        }
275        preds.push(group);
276      }
277    }
278    arms.push(WalkArm { name, preds });
279  }
280  (!arms.is_empty()).then_some(arms)
281}
282
283/// Does `node` satisfy this arm? Name test first — it is a string compare,
284/// while the namespace check wraps a `Namespace` and the ancestor/child atoms
285/// walk.
286fn walk_arm_matches(arm: &WalkArm, node: &Node) -> bool {
287  if let Some(want) = &arm.name
288    && (node.get_name() != *want || !is_ltx(node))
289  {
290    return false;
291  }
292  if arm.preds.is_empty() {
293    return true;
294  }
295  arm.preds.iter().any(|group| {
296    group.iter().all(|pred| match pred {
297      WalkPred::HasAttr(a) => node.has_attribute(a),
298      WalkPred::NoAttr(a) => !node.has_attribute(a),
299      WalkPred::NoAncestor(n) => {
300        let mut cur = node.get_parent();
301        while let Some(p) = cur {
302          if p.get_type() == Some(NodeType::ElementNode) && p.get_name() == *n && is_ltx(&p) {
303            return false;
304          }
305          cur = p.get_parent();
306        }
307        true
308      },
309      WalkPred::NoChild(n) => !node
310        .get_child_elements()
311        .iter()
312        .any(|c| c.get_name() == *n && is_ltx(c)),
313    })
314  })
315}
316
317/// Limit-safe pre-order walk collecting every element matching ANY arm, in
318/// document order, each element pushed at most once — the same contract an
319/// XPath union has.
320fn collect_walk_matches(node: &Node, arms: &[WalkArm], out: &mut Vec<Node>) {
321  if node.get_type() == Some(NodeType::ElementNode)
322    && arms.iter().any(|arm| walk_arm_matches(arm, node))
323  {
324    out.push(node.clone());
325  }
326  // first_child/next_sibling like `collect_split_pages` below — the previous
327  // `get_child_nodes()` materialized a Vec of wrapped nodes per recursion
328  // level, ~2.4% self + allocator share on a whole-document walk that answers
329  // every post `//NAME[pred]` query (2026-08-23 audit R5).
330  let mut child = node.get_first_child();
331  while let Some(c) = child {
332    collect_walk_matches(&c, arms, out);
333    child = c.get_next_sibling();
334  }
335}
336
337/// True when `node` satisfies `arm` (an unconditional arm always matches).
338pub(crate) fn arm_matches(arm: &SplitArm, node: &Node) -> bool {
339  arm.any_of.is_empty() || arm.any_of.iter().any(|c| cond_matches(c, node))
340}
341
342/// Limit-safe pre-order walk collecting the page nodes selected by `arms`, in
343/// document order (no duplicates: each element is tested and pushed at most
344/// once). Replaces XPath evaluation of the split union.
345pub(crate) fn collect_split_pages(node: &Node, arms: &[SplitArm], out: &mut Vec<Node>) {
346  let mut child = node.get_first_child();
347  while let Some(c) = child {
348    if c.get_type() == Some(NodeType::ElementNode) {
349      let name = c.get_name();
350      // Cheap localname gate first; only confirm the `ltx:` namespace and run
351      // the (rare) predicate checks for elements that could actually be pages.
352      if arms.iter().any(|a| a.element == name)
353        && is_ltx(&c)
354        && arms.iter().any(|a| a.element == name && arm_matches(a, &c))
355      {
356        out.push(c.clone());
357      }
358      collect_split_pages(&c, arms, out);
359    }
360    child = c.get_next_sibling();
361  }
362}
363
364/// Post-processing document: wraps an XML document with ID management,
365/// namespace tracking, XPath helpers, and a persistent cache.
366///
367/// Port of `LaTeXML::Post::Document`.
368pub struct PostDocument {
369  /// The underlying XML document.
370  document:                    Document,
371  /// Destination file path for this document.
372  pub destination:             Option<String>,
373  /// Destination directory (derived from destination).
374  pub destination_directory:   Option<String>,
375  /// Site root directory.
376  pub site_directory:          Option<String>,
377  /// Source file path.
378  pub source:                  Option<String>,
379  /// Source directory.
380  pub source_directory:        Option<String>,
381  /// Search paths for resources.
382  pub searchpaths:             Vec<String>,
383  /// Namespace prefix → URI mapping.
384  pub namespaces:              HashMap<String, String>,
385  /// URI → prefix reverse mapping.
386  pub namespace_uris:          HashMap<String, String>,
387  /// ID cache: xml:id → node.
388  idcache:                     HashMap<String, Node>,
389  /// IDs marked as reusable (will be removed later).
390  idcache_reusable:            HashMap<String, bool>,
391  /// IDs reserved but not yet recorded.
392  idcache_reserve:             HashMap<String, bool>,
393  /// Clash counters for uniquifyID.
394  idcache_clashes:             HashMap<String, u32>,
395  /// Processing instructions from the document.
396  pub processing_instructions: Vec<String>,
397  /// Parent document (for split sub-documents).
398  pub parent_document:         Option<Box<PostDocument>>,
399  /// ID of document we were split from.
400  pub split_from_id:           Option<String>,
401  /// Whether to validate the document.
402  pub validate:                bool,
403  /// Simple key-value cache (replaces Perl's DB_File tied hash).
404  cache:                       HashMap<String, String>,
405  /// Whether caching is disabled.
406  pub nocache:                 bool,
407  /// XMath subtrees queued for deferred unlink. Parallel-format
408  /// processors (pmml + cmml) BOTH need the original XMath for their
409  /// per-format `convert_node` pass; if the first processor unlinks
410  /// the subtree, the second's `mark_xm_node_visibility` walks
411  /// stale `XMRef` targets and emits `Error:expected:id Cannot find
412  /// a node with xml:id=…`. Mirrors Perl `Post.pm` L373-393's
413  /// "XMath will be removed (LATER!), but mark its ids as reusable"
414  /// pattern: each per-math `process_math_node` call queues the
415  /// XMath here (and registers its xml:ids as reusable via
416  /// `preremove_nodes`), and a final post-pipeline pass — see
417  /// `drain_pending_unlinks` — does the actual unlink once all
418  /// math-format passes have completed.
419  pending_xmath_unlinks:       Vec<Node>,
420  /// Per-document memo for [`Self::add_navigation`]. The `ltx:navigation`
421  /// element, and the `(rel, idref)` pairs already under it.
422  ///
423  /// Perl `Post.pm:1409-1414` answers both questions with a whole-document
424  /// XPath on EVERY call — a `format!`-built duplicate probe (so it is
425  /// re-COMPILED each time) plus a `//ltx:navigation` walk. That is fine at
426  /// Perl's scale and ruinous at ours: `CrossRef::fill_in_relations` calls this
427  /// once per related page, measured at **406 calls per page** on the
428  /// 40,201-page witness — 16.3 M calls, 32.6 M XPath evaluations, and the
429  /// probe is O(refs already added) so it is quadratic *within* a page too.
430  /// Post-stage self-time profiling is dominated by exactly that: libxml2 XPath
431  /// machinery plus allocator churn (see `KNOWN_PERL_ERRORS.md` #69).
432  ///
433  /// The memo answers identically — it is seeded from the element's own `ltx:ref`
434  /// children, which is what `//ltx:navigation/ltx:ref` selects given the single
435  /// navigation element Perl's own `findnode` assumes.
436  nav_memo:                    Option<NavigationMemo>,
437}
438
439/// Memoized navigation state for one document — see [`PostDocument::nav_memo`].
440struct NavigationMemo {
441  /// The `ltx:navigation` element, revalidated cheaply before reuse.
442  element: Option<Node>,
443  /// `(rel, idref)` pairs already present, standing in for Perl's XPath probe.
444  refs:    FxHashSet<(String, String)>,
445}
446
447impl Drop for PostDocument {
448  /// Rationalize Node lifetime between post-processing components.
449  /// `idcache` entries are Node *handles* into the C-owned libxml
450  /// Document tree — the Document owns the lifetime, Node wrappers
451  /// are lookup references.
452  ///
453  /// libxml 0.3.9's `_Node::drop` fires `xmlFreeNode(ptr)` whenever
454  /// the wrapper's internal `unlinked` flag is true. Math processing
455  /// calls `unlink_node()` on nodes as it replaces XMath subtrees
456  /// with MathML, flipping that flag for nodes still held by
457  /// `idcache`. The resulting drop sequence is:
458  ///   1. `document: Document` (declared first) → `xmlFreeDoc` walks the full tree including
459  ///      still-reachable nodes that share memory with idcache entries; freed.
460  ///   2. `idcache: HashMap<String, Node>` → each Node with `unlinked=true` fires `xmlFreeNode` on
461  ///      already-freed memory → SIGSEGV inside `xmlFreeNodeList`.
462  ///
463  /// Fix: hand each idcache entry to `DocOwnedNode` (see
464  /// `crate::doc_owned_node`), which suppresses the inner Rc's Drop
465  /// so `xmlFreeNode` never fires on already-freed memory.
466  /// `xmlFreeDoc` remains the sole owner of the C node memory.
467  /// Per-entry Rc control block leaks (~24 B) — bounded by
468  /// per-document idcache size and reclaimed at process exit.
469  /// Proper upstream fix: a public `set_linked()` setter on the
470  /// `libxml` crate's `Node`, which would let us relink before drop
471  /// rather than leaking.
472  fn drop(&mut self) {
473    for (_, node) in std::mem::take(&mut self.idcache) {
474      // The document owns the C memory; the wrapper must not free it even if
475      // intermediate processing `unlink_node`ed this entry. `set_linked`
476      // (libxml 0.3.20) declares exactly that, replacing the historical
477      // `DocOwnedNode` leak-wrapper (~100+ bytes per id-cache entry per
478      // document — a real term at 115k documents per process). Entries whose
479      // subtree was `free_subtree`d are already-neutralized no-ops here.
480      node.set_linked();
481    }
482  }
483}
484
485impl PostDocument {
486  /// Create a new PostDocument wrapping an existing XML document.
487  ///
488  /// Port of `Post::Document::new`.
489  pub fn new(doc: Document, options: PostDocumentOptions) -> Self {
490    // Node-mutation aliasing is enforced by libxml's `Node::node_ptr_mut`
491    // (`RefCell::try_borrow_mut`, libxml >= 0.3.14), which ignores benign clone
492    // count, so the former `set_node_rc_guard(128)` band-aid (post-processing
493    // holds many shared id-cache / XPath-result handles) is no longer needed.
494    let mut pd = Self::new_internal(doc, options);
495    pd.set_document_internal();
496    pd
497  }
498
499  fn new_internal(doc: Document, options: PostDocumentOptions) -> Self {
500    let mut dest_dir = options.destination_directory.clone();
501    if options.destination.is_some() && dest_dir.is_none() {
502      if let Some(ref dest) = options.destination {
503        if let Some(parent) = Path::new(dest).parent() {
504          let parent_str = parent.to_string_lossy().to_string();
505          // Empty parent (e.g., from "paper.html") means current directory — use "." not ""
506          if parent_str.is_empty() {
507            dest_dir = Some(".".to_string());
508          } else {
509            dest_dir = Some(parent_str);
510          }
511        }
512      }
513    }
514
515    let site_dir = if let Some(ref sd) = options.site_directory {
516      Some(sd.clone())
517    } else {
518      dest_dir.clone()
519    };
520
521    let mut namespaces = HashMap::default();
522    namespaces.insert("ltx".to_string(), LTX_NSURI.to_string());
523    let mut namespace_uris = HashMap::default();
524    namespace_uris.insert(LTX_NSURI.to_string(), "ltx".to_string());
525
526    PostDocument {
527      document: doc,
528      destination: options.destination,
529      destination_directory: dest_dir,
530      site_directory: site_dir,
531      source: options.source,
532      source_directory: options.source_directory,
533      searchpaths: options.searchpaths.unwrap_or_default(),
534      namespaces,
535      namespace_uris,
536      idcache: HashMap::default(),
537      idcache_reusable: HashMap::default(),
538      idcache_reserve: HashMap::default(),
539      idcache_clashes: HashMap::default(),
540      processing_instructions: Vec::new(),
541      parent_document: None,
542      split_from_id: None,
543      validate: options.validate,
544      cache: HashMap::default(),
545      nocache: options.nocache,
546      pending_xmath_unlinks: Vec::new(),
547      nav_memo: None,
548    }
549  }
550
551  /// Initialize document internals: scan IDs, extract namespaces and PIs.
552  fn set_document_internal(&mut self) {
553    // Record every `xml:id` and `<?latexml …?>` PI in one limit-safe walk (see
554    // `scan_ids_and_pis`), replacing the `//*[@xml:id]` and
555    // `.//processing-instruction('latexml')` queries that returned NULL past the
556    // 10M node-set ceiling — silently building an empty idcache (breaking every
557    // cross-reference) and dropping the searchpath PIs. Walk from the document
558    // node so PIs preceding the root are caught.
559    let mut ids: Vec<(String, Node)> = Vec::new();
560    let mut pis: Vec<String> = Vec::new();
561    scan_ids_and_pis(&self.document.as_node(), &mut ids, &mut pis);
562    for (id, node) in ids {
563      self.idcache.insert(id, node);
564    }
565    self.processing_instructions = pis;
566
567    // Extract namespaces from root element
568    if let Some(root) = self.document.get_root_element() {
569      let ns_decls = root.get_namespace_declarations();
570      for ns in ns_decls {
571        let prefix = ns.get_prefix();
572        if !prefix.is_empty() {
573          let href = ns.get_href();
574          self
575            .namespaces
576            .entry(prefix.clone())
577            .or_insert_with(|| href.clone());
578          self.namespace_uris.entry(href).or_insert(prefix);
579        }
580      }
581    }
582
583    // Extract search paths from PIs
584    let sp_re = Regex::new(r#"^\s*searchpaths\s*=\s*[\"'](.*?)[\"']\s*$"#).unwrap();
585    let mut paths = self.searchpaths.clone();
586    for pi_text in &self.processing_instructions {
587      if let Some(cap) = sp_re.captures(pi_text) {
588        for p in cap[1].split(',') {
589          paths.push(p.trim().to_string());
590        }
591      }
592    }
593    paths.push(".".to_string());
594    self.searchpaths = paths;
595  }
596
597  // ======================================================================
598  // Constructors from various sources
599
600  /// Create from an XML file.
601  ///
602  /// Port of `Post::Document::newFromFile`.
603  ///
604  /// Parses with `XML_PARSE_HUGE` (on top of the crate's default
605  /// recover/noerror/nowarning): without it, libxml2's hard limits corrupt a
606  /// multi-GB parse well before any real malformation — the per-document
607  /// dictionary cap poisons the ID table (hundreds of thousands of bogus
608  /// "ID X already defined" reports for ids that occur exactly once,
609  /// witnessed at ~1.47 GB into the 131 MB book's core XML) and the parse
610  /// dies outright at ~1.71 GB. Post input is our own core serialization,
611  /// not attacker-authored XML, so relaxing the limits is safe.
612  pub fn new_from_file(path: &str, options: PostDocumentOptions) -> Result<Self, String> {
613    let parser = XmlParser::default();
614    let doc = parser
615      .parse_file_with_options(path, huge_parse_options())
616      .map_err(|e| format!("Failed to parse '{}': {}", path, e))?;
617    let mut opts = options;
618    if opts.source.is_none() {
619      opts.source = Some(path.to_string());
620    }
621    if opts.source_directory.is_none() {
622      if let Some(parent) = Path::new(path).parent() {
623        opts.source_directory = Some(parent.to_string_lossy().to_string());
624      }
625    }
626    Ok(Self::new(doc, opts))
627  }
628
629  /// Create from an XML string.
630  ///
631  /// Port of `Post::Document::newFromString`.
632  pub fn new_from_string(xml: &str, options: PostDocumentOptions) -> Result<Self, String> {
633    let parser = XmlParser::default();
634    let doc = parser
635      .parse_string_with_options(xml, huge_parse_options())
636      .map_err(|e| format!("Failed to parse XML string: {}", e))?;
637    let mut opts = options;
638    if opts.source_directory.is_none() {
639      opts.source_directory = Some(".".to_string());
640    }
641    Ok(Self::new(doc, opts))
642  }
643
644  /// Create a new sub-document from an element node.
645  ///
646  /// Port of Perl `Post::Document::newDocument`.
647  /// The element is imported into a fresh XML document.
648  /// Resources, processing instructions, and class attributes are copied from the parent.
649  pub fn new_document(&self, root: Node, destination: &str) -> Self {
650    use libxml::tree::Document as XmlDocument;
651    // Create a fresh XML document that owns a deep copy of `root`'s
652    // subtree as its root element.
653    //
654    // Perl Post.pm L831-839: `XML::LibXML::Document->new(...)` then
655    // `setDocumentElement($doc->importNode($root))`. importNode COPIES
656    // the subtree into the new doc; the new doc and the source no
657    // longer share C-side state.
658    //
659    // We use libxml-rs's `Document::dup_node_into_new_doc` (added in
660    // KWARC/rust-libxml `clone-document` branch). The earlier
661    // `import_node` route had two pitfalls that made it unusable for
662    // the Split.process_pages loop:
663    //   1. `import_node` gates on `Node::is_unlinked()`, a wrapper- side flag with no public
664    //      setter; the gate leaks `false` across iterations because the previous call's
665    //      set_linked() mutated the wrapper Rc, forcing every page after the first to Err.
666    //   2. Direct `xmlDocCopyNode(src, dst, 1)` returns NULL on the second sibling page — the first
667    //      recursive copy dirties dict/ns state on the source doc such that subsequent recursive
668    //      copies fail their child-copy phase (verified: extended=2 still works, isolating the
669    //      failure to recursive descent).
670    // dup_node_into_new_doc avoids both: it does
671    // `xmlCopyNode(node, 1)` (orphan deep copy, no source-doc state
672    // mutation), plants the copy into a freshly created xmlDoc, fixes
673    // up doc pointers via xmlSetTreeDoc, and reconciles namespaces.
674    // The returned Document shares zero C-side state with the source.
675    //
676    // SCOPE: this method is only called from `Split::process_pages`
677    // (split.rs L260); non-split flows do not pay the deep-copy cost.
678    let new_xml_doc: XmlDocument = XmlDocument::dup_node_into_new_doc(&root)
679      .expect("dup_node_into_new_doc returned NULL while creating split sub-document");
680    let _ = root;
681
682    let opts = PostDocumentOptions {
683      destination: Some(destination.to_string()),
684      // CRITICAL: inherit the parent's site_directory so the sub-doc's
685      // `site_relative_destination` carries any intermediate split
686      // directory (e.g. "Ch1/schema.scholarly-ltx.html") into DB
687      // location strings. Without this, every sub-doc defaults
688      // site_directory to its own destination_directory, which makes
689      // every per-doc `location` resolve to just the basename and
690      // CrossRef::generate_url then produces broken in-page anchors
691      // instead of the cross-doc relative URLs that the rendered TOC
692      // is supposed to walk.
693      site_directory: self.site_directory.clone(),
694      source: self.source.clone(),
695      source_directory: self.source_directory.clone(),
696      searchpaths: Some(self.searchpaths.clone()),
697      ..PostDocumentOptions::default()
698    };
699    let mut subdoc = Self::new_internal(new_xml_doc, opts);
700
701    // Copy namespaces
702    subdoc.namespaces = self.namespaces.clone();
703    subdoc.namespace_uris = self.namespace_uris.clone();
704
705    // Record IDs
706    for node in subdoc.findnodes("//*[@xml:id]") {
707      if let Some(id) = get_xml_id(&node) {
708        subdoc.idcache.insert(id, node);
709      }
710    }
711
712    // Record the parent document's root ID
713    if let Some(ref root_el) = self.get_document_element() {
714      if let Some(root_id) = get_xml_id(root_el) {
715        subdoc.split_from_id = Some(root_id);
716      }
717    }
718
719    // Copy processing instructions (Perl Post.pm L766-767).
720    // ABSOLUTE `//…`: `findnodes` with no context node can't evaluate a relative
721    // axis (see `findnodes_at`) — a `.//processing-instruction(...)` here matched
722    // nothing and split children lost the `<?latexml …?>` PIs (#341).
723    for mut pi in self.findnodes("//processing-instruction('latexml')") {
724      if let Ok(mut pi_clone) = subdoc.document.import_node(&mut pi) {
725        if let Some(mut doc_node) = subdoc.document.get_root_element() {
726          doc_node.add_prev_sibling(&mut pi_clone).ok();
727        }
728      }
729    }
730
731    // Copy resource elements (Perl Post.pm L770-771: addNodes for ltx:resource).
732    // ABSOLUTE `//ltx:resource` for the same reason as the PIs above — the old
733    // `descendant::ltx:resource` matched nothing, so split children dropped the
734    // default `LaTeXML.css`/`ltx-book.css` `<link>`s (#341).
735    let resources: Vec<NodeData> = self
736      .findnodes("//ltx:resource")
737      .iter()
738      .map(|r| NodeData::XmlNode(r.clone()))
739      .collect();
740    if !resources.is_empty() {
741      if let Some(mut doc_root) = subdoc.get_document_element() {
742        subdoc.add_nodes(&mut doc_root, &resources);
743      }
744    }
745
746    // If the new document has no date, copy the parent's (Perl Post.pm L774 →
747    // `addDate`, L866-873): when the child's document element has no direct
748    // `ltx:date` child, copy the parent document element's direct `ltx:date`
749    // children. `ltx:date` is a relative (child-axis) path, so it MUST be
750    // evaluated with the document element as an explicit context node —
751    // `findnodes` with no context node can't do relative axes (see above).
752    if let Some(sub_root) = subdoc.get_document_element() {
753      if subdoc.findnodes_at("ltx:date", Some(&sub_root)).is_empty() {
754        if let Some(parent_root) = self.get_document_element() {
755          let dates: Vec<NodeData> = self
756            .findnodes_at("ltx:date", Some(&parent_root))
757            .iter()
758            .map(|d| NodeData::XmlNode(d.clone()))
759            .collect();
760          if !dates.is_empty() {
761            let mut sub_root_mut = sub_root;
762            subdoc.add_nodes(&mut sub_root_mut, &dates);
763          }
764        }
765      }
766    }
767
768    // Copy class from top-level document element (Perl Post.pm L779-782).
769    if let Some(parent_root) = self.get_document_element() {
770      if let Some(pclass) = parent_root.get_attribute("class") {
771        if let Some(mut doc_root) = subdoc.get_document_element() {
772          let existing = doc_root.get_attribute("class").unwrap_or_default();
773          if existing.is_empty() {
774            doc_root.set_attribute("class", &pclass).ok();
775          } else {
776            doc_root
777              .set_attribute("class", &format!("{} {}", existing, pclass))
778              .ok();
779          }
780        }
781      }
782    }
783
784    subdoc
785  }
786
787  // ======================================================================
788  // Accessors
789
790  /// Get a reference to the underlying XML document.
791  pub fn get_document(&self) -> &Document { &self.document }
792
793  /// Get a mutable reference to the underlying XML document.
794  pub fn get_document_mut(&mut self) -> &mut Document { &mut self.document }
795
796  /// Get the document's root element.
797  pub fn get_document_element(&self) -> Option<Node> { self.document.get_root_element() }
798
799  /// Get the source path.
800  pub fn get_source(&self) -> Option<&str> { self.source.as_deref() }
801
802  /// Get the source directory.
803  pub fn get_source_directory(&self) -> &str { self.source_directory.as_deref().unwrap_or(".") }
804
805  /// Get search paths.
806  pub fn get_search_paths(&self) -> &[String] { &self.searchpaths }
807
808  /// Get the destination path.
809  pub fn get_destination(&self) -> Option<&str> { self.destination.as_deref() }
810
811  /// Get the destination directory.
812  pub fn get_destination_directory(&self) -> Option<&str> { self.destination_directory.as_deref() }
813
814  /// Get the site directory.
815  pub fn get_site_directory(&self) -> Option<&str> { self.site_directory.as_deref() }
816
817  /// Return destination relative to site directory.
818  ///
819  /// Port of `siteRelativeDestination`.
820  pub fn site_relative_destination(&self) -> Option<String> {
821    if let (Some(dest), Some(site)) = (&self.destination, &self.site_directory) {
822      Some(pathdiff(dest, site))
823    } else {
824      self.destination.clone()
825    }
826  }
827
828  /// Return a pathname relative to the site directory.
829  pub fn site_relative_pathname(&self, pathname: &str) -> Option<String> {
830    self
831      .site_directory
832      .as_ref()
833      .map(|site| pathdiff(pathname, site))
834  }
835
836  /// Get the destination file extension.
837  pub fn get_destination_extension(&self) -> Option<String> {
838    self.destination.as_ref().and_then(|d| {
839      Path::new(d)
840        .extension()
841        .map(|e| e.to_string_lossy().to_string())
842    })
843  }
844
845  /// Serialize the document to an XML string.
846  pub fn to_xml_string(&self) -> String { self.document.to_string() }
847
848  /// Serialize a single node (and its subtree) to an XML string. Used to
849  /// re-derive raw-string features (e.g. SVG-fragment extraction) from the DOM
850  /// for file-parsed input without ever materializing the whole document.
851  pub fn node_to_string(&self, node: &Node) -> String { self.document.node_to_string(node) }
852
853  /// The `<?latexml …?>` processing instructions collected at parse time
854  /// (searchpaths, loaded packages/classes, RelaxNG schema, …). Used to
855  /// re-derive package-presence sniffs (e.g. `package="ar5iv"`) from the parsed
856  /// document when the raw XML string is not held in memory.
857  pub fn processing_instructions(&self) -> &[String] { &self.processing_instructions }
858
859  pub fn stringify(&self) -> String {
860    format!(
861      "Post::Document[{}]",
862      self
863        .site_relative_destination()
864        .unwrap_or_else(|| "?".to_string())
865    )
866  }
867
868  // ======================================================================
869  // XPath queries
870
871  /// Find nodes matching an XPath expression.
872  ///
873  /// Port of `Post::Document::findnodes`.
874  pub fn findnodes(&self, xpath: &str) -> Vec<Node> { self.findnodes_at(xpath, None) }
875
876  /// Find nodes matching an XPath expression, relative to a given context node.
877  pub fn findnodes_at(&self, xpath: &str, context_node: Option<&Node>) -> Vec<Node> {
878    // Bind the XPath context to the CONTEXT NODE's own document when one is
879    // given — matching XML::LibXML's `XPathContext->findnodes($xpath, $refnode)`,
880    // which resets `ctx->doc = refnode->doc` and so evaluates in the refnode's
881    // document. rust-libxml's `xmlXPathNodeEval` instead REQUIRES
882    // `node->doc == ctx->doc` and returns NULL otherwise (via `xmlXPathSetContextNode`),
883    // so a context node from a *different* document — e.g. an `ltx:bibentry`
884    // loaded from an external `.bib.xml` in MakeBibliography — made
885    // `node_evaluate_checked` report a spurious "XPath evaluation failed" for
886    // every `ltx:bib-name[...]/ltx:surname` probe: a post:xpath warning flood,
887    // *and* the surnames were never matched (the old plain-eval path silently
888    // swallowed the NULL as no-match). `Context::from_node` evaluates in the
889    // node's own document, exactly as Perl does. Same-document callers are
890    // unaffected (from_node then binds to self.document).
891    let ctx = match context_node {
892      Some(node) => XPathContext::from_node(node),
893      None => XPathContext::new(&self.document),
894    };
895    let ctx = match ctx {
896      Ok(c) => c,
897      Err(_) => return vec![],
898    };
899
900    // Register all known namespaces
901    for (prefix, uri) in &self.namespaces {
902      let _ = ctx.register_namespace(prefix, uri);
903    }
904
905    // *_checked so a NULL result — libxml2 aborting, typically a `//X[pred]`
906    // query overflowing the 10M node-set ceiling on a huge document — is logged
907    // instead of silently becoming an empty `vec![]` that corrupts downstream
908    // passes. An empty match set is still `Ok`, so this fires only on a real
909    // abort (CLAUDE.md: fail toward flagging errors).
910    // Whole-document `//NAME[pred]` shapes are answered by TRAVERSAL, never by
911    // a materialized node-set: as XPath they cost O(document) memory and trip
912    // libxml2's 10M node-set ceiling, at which point the evaluation returns
913    // NULL and cannot be distinguished from "nothing matched" (measured: six
914    // such queries failed on a 614 MB core XML and post wrote a 0-byte file
915    // while exiting 0). Unrecognised shapes fall through to libxml2 unchanged.
916    if context_node.is_none()
917      && let Some(arms) = parse_walk_union(xpath)
918      && let Some(root) = self.document.get_root_element()
919    {
920      let mut out = Vec::new();
921      collect_walk_matches(&root, &arms, &mut out);
922      return out;
923    }
924
925    let result = if let Some(node) = context_node {
926      ctx.node_evaluate_checked(xpath, node)
927    } else {
928      // Perl `$doc->findnodes($xpath)` (no ref node) evaluates from the DOCUMENT
929      // NODE, so RELATIVE location paths (`descendant::…`, `.//…`, `child::…`)
930      // resolve against the whole tree. rust-libxml's bare `evaluate_checked`
931      // leaves the XPath context node UNSET, so relative axes silently matched
932      // NOTHING (only absolute `//…`/`/…` worked) — which broke, among others,
933      // `new_document`'s split-page resource/PI copy (#341), `Split`'s
934      // `descendant::ltx:navigation`, and CrossRef's `descendant::ltx:glossaryref`.
935      // We cannot bind the document node itself: rust-libxml's `node_evaluate`
936      // SIGSEGVs on a document-node context (an unguarded FFI path in the fork).
937      // The root ELEMENT is a safe context and makes relative axes resolve for
938      // everything inside the tree. NOTE: nodes OUTSIDE the root element — i.e.
939      // `<?latexml …?>` PIs that precede it — are not descendants of the root, so
940      // a relative PI query still needs the absolute `//processing-instruction()`
941      // form; absolute paths are unaffected by the context node either way.
942      match self.document.get_root_element() {
943        Some(root) => ctx.node_evaluate_checked(xpath, &root),
944        None => ctx.evaluate_checked(xpath),
945      }
946    };
947
948    match result {
949      Ok(obj) => obj.get_nodes_as_vec(),
950      Err(e) => {
951        // NOT a warning, and NOT "no matches": an evaluation that could not be
952        // answered is a failure to know, and downstream passes that treat it as
953        // an empty set silently drop content (CLAUDE.md: fail toward flagging
954        // errors). Raising it as an Error puts it in the tally and the exit
955        // code, so a run like the 0-byte-HTML one cannot report success.
956        Error!(
957          "post",
958          "xpath",
959          "XPath evaluation failed for `{}`: {} — results are INCOMPLETE for this pass",
960          xpath,
961          e
962        );
963        vec![]
964      },
965    }
966  }
967
968  /// Limit-safe evaluation of a `--splitat` page union.
969  ///
970  /// `make_splitpaths` emits `//ltx:X` and
971  /// `//ltx:X[preceding-sibling::ltx:Y or parent::ltx:Z]` arms. As XPath on a
972  /// huge document the predicated arms overflow the 10M node-set ceiling (see
973  /// `scan_ids_and_pis`), the union returns NULL, and nothing splits — the
974  /// whole document stays one page and the downstream XSLT then dies on the same
975  /// ceiling. Instead we parse the arms and select pages with one limit-safe
976  /// walk, applying the predicates in Rust: same nodes, document order, no
977  /// duplicates, as an XPath union.
978  ///
979  /// Falls back to raw XPath for unions outside that grammar (custom
980  /// `--splitpaths`), which only run on small, limit-safe documents.
981  pub fn find_split_pages(&self, union_xpath: &str) -> Vec<Node> {
982    let arms = match parse_split_union(union_xpath) {
983      Some(a) => a,
984      None => return self.findnodes(union_xpath),
985    };
986    let mut pages = Vec::new();
987    collect_split_pages(&self.document.as_node(), &arms, &mut pages);
988    pages
989  }
990
991  /// Find the first node matching an XPath expression.
992  pub fn findnode(&self, xpath: &str) -> Option<Node> { self.findnodes(xpath).into_iter().next() }
993
994  /// Find the first node matching an XPath expression, relative to a context node.
995  pub fn findnode_at(&self, xpath: &str, context_node: &Node) -> Option<Node> {
996    self
997      .findnodes_at(xpath, Some(context_node))
998      .into_iter()
999      .next()
1000  }
1001
1002  /// Evaluate an XPath expression and return the string value.
1003  pub fn findvalue(&self, xpath: &str) -> Option<String> {
1004    let ctx = XPathContext::new(&self.document).ok()?;
1005    for (prefix, uri) in &self.namespaces {
1006      let _ = ctx.register_namespace(prefix, uri);
1007    }
1008    ctx.evaluate(xpath).ok().map(|obj| obj.to_string())
1009  }
1010
1011  /// XPath query on an arbitrary node, even if from a different document.
1012  /// Creates a temporary XPath context on the node's own document.
1013  pub fn findnodes_foreign(xpath: &str, node: &Node) -> Vec<Node> {
1014    // Navigate up to find the document root, then create context
1015    let mut current = node.clone();
1016    while let Some(parent) = current.get_parent() {
1017      current = parent;
1018    }
1019    // current is the document root (or the node itself if detached)
1020    // Get the document for this node tree
1021    if let Some(doc) = current.get_parent() {
1022      // Has a parent = we're at root element, doc is parent
1023      let _ = doc; // can't use this easily
1024    }
1025    // Fallback: use libxml's node_evaluate with a fresh context
1026    // We need to use the internal document. libxml2 nodes know their document.
1027    #[allow(unused_imports)]
1028    use libxml::xpath::Context as XPathContext;
1029    // Create context from the document that owns this node
1030    // node._node_ptr -> xmlNodePtr -> doc field
1031    // Unfortunately, libxml2-rs doesn't expose a way to get the document from a node.
1032    // Workaround: build a new document wrapping this subtree.
1033    // Simpler workaround: just traverse children manually for common patterns.
1034    Self::findnodes_by_traversal(xpath, node)
1035  }
1036
1037  /// Manual node traversal for common XPath patterns used in bibliography formatting.
1038  /// Handles: "ltx:bib-name[@role='author']", "ltx:bib-title", "ltx:bib-date[@role='publication']",
1039  /// "ltx:bib-related/ltx:bib-title", "ltx:bib-part[@role='volume']", etc.
1040  fn findnodes_by_traversal(xpath: &str, parent: &Node) -> Vec<Node> {
1041    let xpath = xpath.trim_start_matches('!').trim();
1042    let mut results = Vec::new();
1043
1044    // Parse simple patterns: "ltx:elem" or "ltx:elem[@attr='val']" or "ltx:elem/ltx:child".
1045    // Split on '/' only at bracket depth 0, so a '/' inside a predicate (e.g. the
1046    // `../` in `[not(../ltx:bib-related[@bibrefs])]`) does not fragment the step.
1047    let parts: Vec<&str> = split_steps(xpath);
1048    if parts.is_empty() {
1049      return results;
1050    }
1051
1052    // Split a path into steps on '/' at bracket depth 0.
1053    fn split_steps(xpath: &str) -> Vec<&str> {
1054      let mut steps = Vec::new();
1055      let mut depth = 0i32;
1056      let mut start = 0;
1057      for (i, b) in xpath.bytes().enumerate() {
1058        match b {
1059          b'[' => depth += 1,
1060          b']' => depth -= 1,
1061          b'/' if depth == 0 => {
1062            steps.push(&xpath[start..i]);
1063            start = i + 1;
1064          },
1065          _ => {},
1066        }
1067      }
1068      steps.push(&xpath[start..]);
1069      steps
1070    }
1071
1072    // Extract the top-level `[...]` predicate bodies from a step, respecting
1073    // nested brackets: `[@type][not(../ltx:bib-related[@bibrefs])]`
1074    // → ["@type", "not(../ltx:bib-related[@bibrefs])"].
1075    fn extract_predicates(s: &str) -> Vec<&str> {
1076      let mut preds = Vec::new();
1077      let mut depth = 0i32;
1078      let mut start = 0;
1079      for (i, b) in s.bytes().enumerate() {
1080        match b {
1081          b'[' => {
1082            if depth == 0 {
1083              start = i + 1;
1084            }
1085            depth += 1;
1086          },
1087          b']' => {
1088            depth -= 1;
1089            if depth == 0 {
1090              preds.push(&s[start..i]);
1091            }
1092          },
1093          _ => {},
1094        }
1095      }
1096      preds
1097    }
1098
1099    fn match_element(node: &Node, pattern: &str) -> bool {
1100      let pattern = pattern.trim().trim_start_matches("ltx:");
1101      let bracket_pos = match pattern.find('[') {
1102        None => return node.get_name() == pattern,
1103        Some(p) => p,
1104      };
1105      let elem_name = &pattern[..bracket_pos];
1106      if node.get_name() != elem_name {
1107        return false;
1108      }
1109      // Every top-level predicate must hold. Supported forms: `@attr` (the
1110      // attribute must exist) and `@attr='value'` (equality). Function
1111      // predicates such as `not(../ltx:bib-related[@bibrefs])` are beyond this
1112      // lightweight matcher and are treated as satisfied (best-effort).
1113      for pred in extract_predicates(&pattern[bracket_pos..]) {
1114        let pred = pred.trim();
1115        if pred.contains('(') {
1116          continue;
1117        }
1118        if let Some(attr) = pred.strip_prefix('@') {
1119          if let Some(eq) = attr.find('=') {
1120            let name = attr[..eq].trim();
1121            let val = attr[eq + 1..].trim().trim_matches('\'').trim_matches('"');
1122            if node.get_attribute(name).as_deref() != Some(val) {
1123              return false;
1124            }
1125          } else if node.get_attribute(attr.trim()).is_none() {
1126            return false;
1127          }
1128        }
1129      }
1130      true
1131    }
1132
1133    fn collect_matching(node: &Node, parts: &[&str], results: &mut Vec<Node>) {
1134      if parts.is_empty() {
1135        return;
1136      }
1137      let pattern = parts[0];
1138      // Handle "A | B" alternatives
1139      let alternatives: Vec<&str> = pattern.split('|').map(|s| s.trim()).collect();
1140      let mut child = node.get_first_child();
1141      while let Some(c) = child {
1142        for alt in &alternatives {
1143          if match_element(&c, alt) {
1144            if parts.len() == 1 {
1145              results.push(c.clone());
1146            } else {
1147              collect_matching(&c, &parts[1..], results);
1148            }
1149          }
1150        }
1151        child = c.get_next_sibling();
1152      }
1153    }
1154
1155    collect_matching(parent, &parts, &mut results);
1156    results
1157  }
1158
1159  // ======================================================================
1160  // Namespace management
1161
1162  /// Register a new namespace prefix → URI mapping.
1163  ///
1164  /// Port of `Post::Document::addNamespace`.
1165  pub fn add_namespace(&mut self, prefix: &str, nsuri: &str) {
1166    let dominated = self
1167      .namespaces
1168      .get(prefix)
1169      .map(|u| u == nsuri)
1170      .unwrap_or(false);
1171    if !dominated {
1172      self
1173        .namespaces
1174        .insert(prefix.to_string(), nsuri.to_string());
1175      self
1176        .namespace_uris
1177        .insert(nsuri.to_string(), prefix.to_string());
1178      // Declare the namespace on the root element (without changing its own namespace).
1179      // Namespace::new() creates the declaration; we do NOT call set_namespace()
1180      // which would change the root element's own namespace.
1181      if let Some(mut root) = self.document.get_root_element() {
1182        let _ = Namespace::new(prefix, nsuri, &mut root);
1183      }
1184    }
1185  }
1186
1187  /// Get the qualified name (prefix:localname) for a node.
1188  ///
1189  /// Port of `Post::Document::getQName`.
1190  pub fn get_qname(&self, node: &Node) -> Option<String> {
1191    if node.get_type() != Some(NodeType::ElementNode) {
1192      return None;
1193    }
1194    let localname = node.get_name();
1195    if let Some(ns) = node.get_namespace() {
1196      let nsuri = ns.get_href();
1197      if let Some(prefix) = self.namespace_uris.get(&nsuri) {
1198        Some(format!("{}:{}", prefix, localname))
1199      } else {
1200        // Auto-generate a prefix for unknown namespaces
1201        let n = self
1202          .namespaces
1203          .keys()
1204          .filter(|k| k.starts_with("_ns"))
1205          .count()
1206          + 1;
1207        Some(format!("_ns{}:{}", n, localname))
1208      }
1209    } else {
1210      Some(localname)
1211    }
1212  }
1213
1214  /// Resolve a node's namespace URI to its registered prefix without
1215  /// allocating a combined "prefix:localname". Returns the prefix as an
1216  /// owned `String` (a copy of the entry in `namespace_uris`); callers
1217  /// can then match on `node.get_name()` separately. Useful in hot
1218  /// dispatch code where the `format!` in `get_qname` is the cost.
1219  pub fn qname_prefix(&self, node: &Node) -> Option<String> {
1220    if node.get_type() != Some(NodeType::ElementNode) {
1221      return None;
1222    }
1223    node.get_namespace().and_then(|ns| {
1224      let nsuri = ns.get_href();
1225      self.namespace_uris.get(&nsuri).cloned()
1226    })
1227  }
1228
1229  /// Check whether a node's qualified name equals a fixed "prefix:localname"
1230  /// string without allocating a `String`. Fast-path for hot comparisons
1231  /// like `is_qname(node, "ltx:XMApp")` — avoids the `format!` in
1232  /// `get_qname` when the caller only needs a boolean answer. Falls back
1233  /// to allocating comparison (via `get_qname`) for unknown-namespace
1234  /// cases so semantics exactly match `get_qname(node).as_deref() == Some(...)`.
1235  pub fn is_qname(&self, node: &Node, expected: &str) -> bool {
1236    if node.get_type() != Some(NodeType::ElementNode) {
1237      return false;
1238    }
1239    let (expected_prefix, expected_local) = match expected.split_once(':') {
1240      Some((p, l)) => (Some(p), l),
1241      None => (None, expected),
1242    };
1243    let localname = node.get_name();
1244    if localname != expected_local {
1245      return false;
1246    }
1247    match (node.get_namespace(), expected_prefix) {
1248      (Some(ns), Some(ep)) => {
1249        let nsuri = ns.get_href();
1250        self
1251          .namespace_uris
1252          .get(&nsuri)
1253          .map(|p| p == ep)
1254          .unwrap_or(false)
1255      },
1256      (None, None) => true,
1257      _ => false,
1258    }
1259  }
1260
1261  // ======================================================================
1262  // ID management
1263
1264  /// Record an ID → node mapping.
1265  ///
1266  /// Port of `Post::Document::recordID`.
1267  pub fn record_id(&mut self, id: &str, node: Node) {
1268    self.idcache.insert(id.to_string(), node);
1269    self.idcache_reserve.remove(id);
1270    self.idcache_reusable.remove(id);
1271  }
1272
1273  /// Find a node by its xml:id.
1274  ///
1275  /// Port of `Post::Document::findNodeByID`.
1276  pub fn find_node_by_id(&self, id: &str) -> Option<&Node> { self.idcache.get(id) }
1277
1278  /// Number of id-bearing nodes registered in this document's idcache.
1279  /// This is exactly the node set Perl's `//@xml:id` iterates.
1280  pub fn idcache_len(&self) -> usize { self.idcache.len() }
1281
1282  /// Iterate `(id, node)` for every id-bearing node in this document, in
1283  /// arbitrary order. Mirrors Perl's `//@xml:id` traversal (per-document,
1284  /// so bounded by the page size rather than the global ObjectDB).
1285  pub fn idcache_iter(&self) -> impl Iterator<Item = (&String, &Node)> { self.idcache.iter() }
1286
1287  /// Generate a unique ID based on `baseid`, optionally applying a suffix.
1288  ///
1289  /// If the resulting ID is already used (and not marked reusable),
1290  /// appends alphabetic suffixes (a, b, c, ...) until unique.
1291  ///
1292  /// Port of `Post::Document::uniquifyID`.
1293  pub fn uniquify_id(&mut self, baseid: &str, suffix: Option<&str>) -> String {
1294    let apply_suffix = |id: &str, sfx: Option<&str>| -> String {
1295      if let Some(s) = sfx {
1296        format!("{}{}", id, s)
1297      } else {
1298        id.to_string()
1299      }
1300    };
1301
1302    let mut id = apply_suffix(baseid, suffix);
1303    let cachekey = id.clone();
1304
1305    while (self.idcache.contains_key(&id) || self.idcache_reserve.contains_key(&id))
1306      && !self.idcache_reusable.contains_key(&id)
1307    {
1308      let clash_count = self.idcache_clashes.entry(cachekey.clone()).or_insert(0);
1309      *clash_count += 1;
1310      id = apply_suffix(&format!("{}{}", baseid, radix_alpha(*clash_count)), suffix);
1311    }
1312
1313    self.idcache_reusable.remove(&id);
1314    self.idcache_reserve.insert(id.clone(), true);
1315    id
1316  }
1317
1318  /// Generate, add, and register an xml:id for a node.
1319  ///
1320  /// Creates a structured ID relative to the nearest parent with an ID.
1321  ///
1322  /// Port of `Post::Document::generateNodeID`.
1323  pub fn generate_node_id(
1324    &mut self,
1325    node: &mut Node,
1326    prefix: &str,
1327    reusable: bool,
1328  ) -> Option<String> {
1329    if let Some(id) = get_xml_id(node) {
1330      return Some(id);
1331    }
1332
1333    // Find the closest parent with an ID (NS-aware — the bare read always
1334    // missed, so generated ids silently lost their parent prefix, e.g.
1335    // "fn1" instead of "S2.fn1")
1336    let mut parent_node = node.get_parent();
1337    let mut pid = String::new();
1338    while let Some(ref p) = parent_node {
1339      if let Some(id) = get_xml_id(p) {
1340        pid = id;
1341        break;
1342      }
1343      parent_node = p.get_parent();
1344    }
1345
1346    if !pid.is_empty() {
1347      pid.push('.');
1348    }
1349
1350    // Find the next unused ID
1351    let mut n = 1u32;
1352    let id = loop {
1353      let candidate = format!("{}{}{}", pid, prefix, n);
1354      if !self.idcache.contains_key(&candidate) && !self.idcache_reserve.contains_key(&candidate) {
1355        break candidate;
1356      }
1357      n += 1;
1358    };
1359
1360    node.set_attribute("xml:id", &id).ok();
1361    let node_copy = node.clone();
1362    self.idcache.insert(id.clone(), node_copy);
1363    if reusable {
1364      self.idcache_reusable.insert(id.clone(), true);
1365    }
1366
1367    // If the parent has a fragid, create one here too
1368    if let Some(ref p) = parent_node {
1369      if p.get_attribute("fragid").is_some() {
1370        let new_fragid = format!("{}.{}{}", p.get_attribute("fragid").unwrap(), prefix, n);
1371        node.set_attribute("fragid", &new_fragid).ok();
1372      }
1373    }
1374
1375    Some(id)
1376  }
1377
1378  // ======================================================================
1379  // Node manipulation
1380
1381  /// Add nodes to `parent` using the recursive representation.
1382  ///
1383  /// Port of `Post::Document::addNodes`.
1384  pub fn add_nodes(&mut self, parent: &mut Node, data: &[NodeData]) {
1385    for child in data {
1386      match child {
1387        NodeData::Text(text) => {
1388          parent.append_text(text).ok();
1389        },
1390        NodeData::Element { tag, attributes, children } => {
1391          // Belt-and-suspenders invariant: never materialize an empty
1392          // `<mi></mi>`. `<mi>` is a semantic assertion ("here is an
1393          // identifier") with no defined meaning when empty — renderers
1394          // vary, screen readers announce "blank", search/indexing
1395          // tools pollute their indexes. The right placeholder is
1396          // `<mrow></mrow>` (presentational, no semantic claim).
1397          // Catches any future code path that re-introduces the
1398          // antipattern via a different route. Task #264.
1399          debug_assert!(
1400            !((tag == "m:mi" || tag == "mi") && children.is_empty()),
1401            "Empty <mi></mi> detected at materialization — use <mrow></mrow> \
1402             scaffolding instead; see task #264 in docs/SYNC_STATUS.md"
1403          );
1404          if tag == "_Fragment_" {
1405            self.add_nodes(parent, children);
1406          } else if let Some((prefix, localname)) = tag.split_once(':') {
1407            let nsuri = self.namespaces.get(prefix).cloned();
1408            if nsuri.is_none() {
1409              Warn!("malformed", "namespace", "No namespace on '{}'", tag);
1410            }
1411            // Find or create namespace for this prefix.
1412            // Prefer the default namespace (empty prefix) if it matches the target URI,
1413            // so elements like ltx:ref are created as <ref> not <ltx:ref>.
1414            let ns = nsuri.and_then(|uri| {
1415              // First check if the default namespace matches — prefer it to avoid ltx: prefix
1416              parent
1417                .get_namespace_declarations()
1418                .into_iter()
1419                .find(|ns| ns.get_prefix().is_empty() && ns.get_href() == uri)
1420                .or_else(|| {
1421                  parent
1422                    .get_namespaces(&self.document)
1423                    .into_iter()
1424                    .find(|ns| ns.get_prefix().is_empty() && ns.get_href() == uri)
1425                })
1426                // Fall back to matching prefix
1427                .or_else(|| {
1428                  parent
1429                    .get_namespace_declarations()
1430                    .into_iter()
1431                    .find(|ns| ns.get_prefix() == prefix)
1432                })
1433                .or_else(|| {
1434                  parent
1435                    .get_namespaces(&self.document)
1436                    .into_iter()
1437                    .find(|ns| ns.get_prefix() == prefix)
1438                })
1439                .or_else(|| {
1440                  // Create a new declaration
1441                  Namespace::new(prefix, &uri, parent).ok()
1442                })
1443            });
1444            if let Ok(mut new_node) = parent.new_child(ns, localname) {
1445              // Set attributes
1446              if let Some(attrs) = attributes {
1447                let mut sorted_keys: Vec<_> = attrs.keys().collect();
1448                sorted_keys.sort();
1449                for key in sorted_keys {
1450                  let value = &attrs[key];
1451                  if key.starts_with('_') {
1452                    continue;
1453                  }
1454                  if key == "xml:id" {
1455                    let id = if self.idcache.contains_key(value.as_str()) {
1456                      self.uniquify_id(value, None)
1457                    } else {
1458                      value.clone()
1459                    };
1460                    self.record_id(&id, new_node.clone());
1461                    new_node.set_attribute("xml:id", &id).ok();
1462                  } else {
1463                    new_node.set_attribute(key, value).ok();
1464                  }
1465                }
1466              }
1467              self.add_nodes(&mut new_node, children);
1468            }
1469          } else {
1470            Warn!(
1471              "malformed",
1472              "namespace",
1473              "Tag '{}' has no namespace prefix",
1474              tag
1475            );
1476          }
1477        },
1478        NodeData::XmlNode(source_node) => {
1479          self.append_clone(parent, source_node);
1480        },
1481      }
1482    }
1483  }
1484
1485  /// Deep-clone an existing node subtree and append it under `parent`, giving
1486  /// the copy fresh (non-clashing) ids — the faithful equivalent of Perl
1487  /// `Post::Document::cloneNode` + insert.
1488  ///
1489  /// [`clone_subtree`](Self::clone_subtree) does the structural deep copy and
1490  /// uniquifies every `xml:id` inline (remapping each node's own `fragid`),
1491  /// recording old→new in `idmap`. This pass then remaps `idref` attributes
1492  /// through that map and drops `labels`, mirroring Perl `cloneNode`
1493  /// (`Post.pm` L1256-1287). Uniquification matters because the source stays
1494  /// in the tree: e.g. a section-title `<ltx:Math xml:id=…>` cloned into the
1495  /// table of contents must not duplicate the body copy's id (issue #356) —
1496  /// the HTML `id` is emitted from `fragid` by the XSLT `add_id` template, so
1497  /// `fragid` must be remapped too.
1498  fn append_clone(&mut self, parent: &mut Node, source: &Node) {
1499    let mut idmap: HashMap<String, String> = HashMap::default();
1500    let Some(root) = self.clone_subtree(parent, source, &mut idmap) else {
1501      return;
1502    };
1503    if !idmap.is_empty() {
1504      for mut n in self.findnodes_at("descendant-or-self::*[@idref]", Some(&root)) {
1505        if let Some(idref) = n.get_attribute("idref") {
1506          if let Some(newid) = idmap.get(&idref) {
1507            n.set_attribute("idref", newid).ok();
1508          }
1509        }
1510      }
1511    }
1512    for mut n in self.findnodes_at("descendant-or-self::*[@labels]", Some(&root)) {
1513      let _ = n.remove_attribute("labels");
1514    }
1515  }
1516
1517  /// Structural half of [`append_clone`](Self::append_clone): recursively deep
1518  /// copy `source` under `parent`, uniquifying each element's `xml:id` inline
1519  /// (so the tree never carries a transient duplicate id) and remapping its
1520  /// `fragid`. Records each old→new id in `idmap` for the caller's `idref`
1521  /// remap. Returns the new element root (for the caller's post-pass), or
1522  /// `None` for text/fragment sources.
1523  fn clone_subtree(
1524    &mut self,
1525    parent: &mut Node,
1526    source: &Node,
1527    idmap: &mut HashMap<String, String>,
1528  ) -> Option<Node> {
1529    match source.get_type() {
1530      Some(NodeType::ElementNode) => {
1531        let localname = source.get_name();
1532        // Resolve the namespace in the TARGET document. Reusing `source`'s own
1533        // `Namespace` (which belongs to the SOURCE document's tree) in
1534        // `new_child` plants a cross-document `xmlNs` pointer, which SIGSEGVs /
1535        // corrupts the tree once either document is freed — the crash that hit
1536        // `newDocument`'s cross-doc `ltx:resource` copy for split pages (#341).
1537        // Mirror the `NodeData::Element` path: find or create a namespace with
1538        // the same URI (preferring the default/empty prefix) on the target
1539        // parent's own document.
1540        let ns = source.get_namespace().and_then(|src_ns| {
1541          let uri = src_ns.get_href();
1542          let prefix = src_ns.get_prefix();
1543          parent
1544            .get_namespace_declarations()
1545            .into_iter()
1546            .find(|n| n.get_prefix().is_empty() && n.get_href() == uri)
1547            .or_else(|| {
1548              parent
1549                .get_namespaces(&self.document)
1550                .into_iter()
1551                .find(|n| n.get_prefix().is_empty() && n.get_href() == uri)
1552            })
1553            .or_else(|| {
1554              parent
1555                .get_namespace_declarations()
1556                .into_iter()
1557                .find(|n| n.get_prefix() == prefix)
1558            })
1559            .or_else(|| {
1560              parent
1561                .get_namespaces(&self.document)
1562                .into_iter()
1563                .find(|n| n.get_prefix() == prefix)
1564            })
1565            .or_else(|| Namespace::new(&prefix, &uri, parent).ok())
1566        });
1567        let mut new_node = parent.new_child(ns, &localname).ok()?;
1568
1569        // The namespaced `xml:id` is the ONE id LaTeXML uses (there is no bare
1570        // non-namespaced `id` in the model); `get_properties` surfaces it under
1571        // its localname "id", so read it namespace-aware via `get_xml_id`.
1572        let src_xmlid = get_xml_id(source);
1573        let src_fragid = source.get_attribute("fragid");
1574        let new_xmlid = src_xmlid.as_ref().map(|id| {
1575          let newid = if self.idcache.contains_key(id.as_str()) {
1576            self.uniquify_id(id, None)
1577          } else {
1578            id.clone()
1579          };
1580          idmap.insert(id.clone(), newid.clone());
1581          self.record_id(&newid, new_node.clone());
1582          newid
1583        });
1584        // Perl `cloneNode`: `fragid => substr($newid, length($id) - length($fragid))`.
1585        let new_fragid = match (&src_xmlid, &new_xmlid, &src_fragid) {
1586          (Some(id), Some(newid), Some(fragid)) => Some(remap_fragid(id, newid, fragid)),
1587          _ => src_fragid.clone(),
1588        };
1589
1590        // Copy the remaining attributes verbatim; the xml:id and fragid are set
1591        // explicitly (above/below) from their remapped values.
1592        for (key, value) in &source.get_properties() {
1593          if key.starts_with('_') || key == "fragid" {
1594            continue;
1595          }
1596          let is_xmlid = key == "xml:id" || (key == "id" && src_xmlid.as_deref() == Some(value));
1597          if is_xmlid {
1598            continue;
1599          }
1600          new_node.set_attribute(key, value).ok();
1601        }
1602        if let Some(newid) = &new_xmlid {
1603          new_node.set_attribute("xml:id", newid).ok();
1604        }
1605        if let Some(fragid) = &new_fragid {
1606          new_node.set_attribute("fragid", fragid).ok();
1607        }
1608
1609        // Recurse into children, sharing the id map.
1610        let mut child = source.get_first_child();
1611        while let Some(c) = child {
1612          self.clone_subtree(&mut new_node, &c, idmap);
1613          child = c.get_next_sibling();
1614        }
1615        Some(new_node)
1616      },
1617      Some(NodeType::TextNode) => {
1618        parent.append_text(&source.get_content()).ok();
1619        None
1620      },
1621      Some(NodeType::DocumentFragNode) => {
1622        let mut child = source.get_first_child();
1623        while let Some(c) = child {
1624          self.clone_subtree(parent, &c, idmap);
1625          child = c.get_next_sibling();
1626        }
1627        None
1628      },
1629      _ => None,
1630    }
1631  }
1632
1633  /// Remove nodes from the document, cleaning up ID caches.
1634  ///
1635  /// Port of `Post::Document::removeNodes`.
1636  pub fn remove_nodes(&mut self, nodes: &[Node]) {
1637    fn collect_ids_of_subtree(node: &Node, out: &mut Vec<String>) {
1638      if node.get_type() != Some(NodeType::ElementNode) {
1639        return;
1640      }
1641      if let Some(id) = get_xml_id(node) {
1642        out.push(id);
1643      }
1644      let mut child = node.get_first_child();
1645      while let Some(c) = child {
1646        collect_ids_of_subtree(&c, out);
1647        child = c.get_next_sibling();
1648      }
1649    }
1650
1651    for node in nodes {
1652      if node.get_type() == Some(NodeType::ElementNode) {
1653        // Walk the subtree directly to enumerate xml:id descendants.
1654        let mut ids = Vec::new();
1655        collect_ids_of_subtree(node, &mut ids);
1656        for id in ids {
1657          self.idcache.remove(&id);
1658        }
1659      }
1660      let mut n = node.clone();
1661      n.unlink_node();
1662    }
1663  }
1664
1665  /// Mark nodes as "will be removed later" — their IDs become reusable.
1666  ///
1667  /// Port of `Post::Document::preremoveNodes`.
1668  pub fn preremove_nodes(&mut self, nodes: &[Node]) {
1669    for node in nodes {
1670      if node.get_type() == Some(NodeType::ElementNode) {
1671        for idd in self.findnodes_at("descendant-or-self::*[@xml:id]", Some(node)) {
1672          // NS-aware read — the bare form always returned None, so no id
1673          // was ever marked reusable and generate_node_id skipped reuse.
1674          if let Some(id) = get_xml_id(&idd) {
1675            self.idcache_reusable.insert(id, true);
1676          }
1677        }
1678      }
1679    }
1680  }
1681
1682  /// Queue an XMath subtree for unlinking at the end of post-processing.
1683  ///
1684  /// Mirrors Perl `Post.pm` L373-393's "XMath will be removed (LATER!),
1685  /// but mark its ids as reusable" pattern. The actual unlink happens
1686  /// in [`drain_pending_xmath_unlinks`](Self::drain_pending_xmath_unlinks),
1687  /// which the post-pipeline
1688  /// invokes once *all* math-format processors have completed. Without
1689  /// the defer, parallel-format chains (pmml + cmml) lose the XMath
1690  /// subtree on the first processor's unlink and the second
1691  /// processor's `mark_xm_node_visibility` walks stale `XMRef`
1692  /// targets, emitting `Error:expected:id Cannot find a node with
1693  /// xml:id=…`.
1694  pub fn defer_xmath_unlink(&mut self, node: Node) { self.pending_xmath_unlinks.push(node); }
1695
1696  /// Drain the deferred XMath unlinks: actually detach each subtree
1697  /// from the document. Idempotent against multiple processors
1698  /// queueing the same node — the second `unlink_node` is a no-op on
1699  /// an already-detached subtree. The deferred subtrees are wrapped
1700  /// in `DocOwnedNode` to suppress libxml's `_Node::drop` →
1701  /// `xmlFreeNode` chain; the enclosing Document remains the sole
1702  /// owner. See `math_processor::process_math_node` for the prior
1703  /// in-place wrapping pattern.
1704  pub fn drain_pending_xmath_unlinks(&mut self) {
1705    let pending = std::mem::take(&mut self.pending_xmath_unlinks);
1706    for node in pending {
1707      // Detach AND FREE the subtree (`free_subtree` unlinks first; it also
1708      // neutralizes every registered wrapper into the subtree, id-cache
1709      // entries included, so no stale handle can double-free). The previous
1710      // `DocOwnedNode` wrapper only suppressed the wrapper's drop — the
1711      // detached XMath C subtree itself was left unreachable-but-mapped,
1712      // i.e. LEAKED per formula per page: a leading term of the measured
1713      // ~152 KB/page render retention on the math-dense 131 MB witness.
1714      node.free_subtree();
1715    }
1716  }
1717
1718  /// Remove blank (whitespace-only) text nodes that are direct children of `node`.
1719  ///
1720  /// Port of `Post::Document::removeBlankNodes`.
1721  pub fn remove_blank_nodes(&self, node: &Node) -> u32 {
1722    let mut count = 0;
1723    if let Some(child) = node.get_first_child() {
1724      let mut current = Some(child);
1725      while let Some(ref mut c) = current {
1726        let next = c.get_next_sibling();
1727        if c.get_type() == Some(NodeType::TextNode) {
1728          let text = c.get_content();
1729          if text.trim().is_empty() {
1730            c.unlink_node();
1731            count += 1;
1732          }
1733        }
1734        current = next;
1735      }
1736    }
1737    count
1738  }
1739
1740  /// Replace `node` with `replacements` in the document.
1741  ///
1742  /// Port of `Post::Document::replaceNode`.
1743  pub fn replace_node(&mut self, old_node: &Node, replacements: &[NodeData]) {
1744    if let Some(mut parent) = old_node.get_parent() {
1745      // Save following siblings
1746      let mut save = Vec::new();
1747      while let Some(mut last) = parent.get_last_child() {
1748        if last == *old_node {
1749          break;
1750        }
1751        last.unlink_node();
1752        save.insert(0, last);
1753      }
1754
1755      // Remove the old node
1756      self.remove_nodes(&[old_node.clone()]);
1757
1758      // Add replacements
1759      self.add_nodes(&mut parent, replacements);
1760
1761      // Re-append saved siblings
1762      for mut s in save {
1763        parent.add_child(&mut s).ok();
1764      }
1765    }
1766  }
1767
1768  /// Prepend `nodes` as the first children of `parent`.
1769  ///
1770  /// Port of `Post::Document::prependNodes`.
1771  pub fn prepend_nodes(&mut self, parent: &mut Node, nodes: &[NodeData]) {
1772    // Save all existing children
1773    let mut save = Vec::new();
1774    while let Some(mut last) = parent.get_last_child() {
1775      last.unlink_node();
1776      save.insert(0, last);
1777    }
1778
1779    // Add new nodes first
1780    self.add_nodes(parent, nodes);
1781
1782    // Re-append original children
1783    for mut s in save {
1784      parent.add_child(&mut s).ok();
1785    }
1786  }
1787
1788  // The faithful `Post::Document::cloneNode` port lives in
1789  // [`append_clone`](Self::append_clone) / [`clone_subtree`](Self::clone_subtree),
1790  // which deep-copy into a parent (rust-libxml's `Node::clone` is only a
1791  // reference copy, so an in-place remap would corrupt the source).
1792
1793  // ======================================================================
1794  // CSS class and style management
1795
1796  /// Add space-separated values to an attribute, deduplicating and sorting.
1797  ///
1798  /// Port of `Post::Document::addSSValues`.
1799  pub fn add_ss_values(node: &mut Node, key: &str, values: &str) {
1800    if values.is_empty() {
1801      return;
1802    }
1803    let new_values: Vec<&str> = values.split_whitespace().collect();
1804    if let Some(old_values_str) = node.get_attribute(key) {
1805      let mut all: Vec<String> = old_values_str
1806        .split_whitespace()
1807        .map(String::from)
1808        .collect();
1809      for v in &new_values {
1810        if !all.iter().any(|o| o == v) {
1811          all.push(v.to_string());
1812        }
1813      }
1814      all.sort();
1815      node.set_attribute(key, &all.join(" ")).ok();
1816    } else {
1817      let mut sorted: Vec<&str> = new_values;
1818      sorted.sort_unstable();
1819      node.set_attribute(key, &sorted.join(" ")).ok();
1820    }
1821  }
1822
1823  /// Add CSS class(es) to a node.
1824  ///
1825  /// Port of `Post::Document::addClass`.
1826  pub fn add_class(node: &mut Node, class: &str) { Self::add_ss_values(node, "class", class); }
1827
1828  // ======================================================================
1829  // XMath visibility marking
1830
1831  /// Mark XMath node visibility (content vs presentation branches).
1832  ///
1833  /// Port of `Post::Document::markXMNodeVisibility`.
1834  pub fn mark_xm_node_visibility(&self) {
1835    for mut math_child in self.findnodes("//ltx:XMath/*") {
1836      self.mark_xm_node_visibility_aux(&mut math_child, true, true);
1837    }
1838  }
1839
1840  fn mark_xm_node_visibility_aux(&self, node: &mut Node, cvis: bool, pvis: bool) {
1841    let qname = match self.get_qname(node) {
1842      Some(q) => q,
1843      None => return,
1844    };
1845
1846    let has_cvis = node.get_attribute("_cvis").is_some();
1847    let has_pvis = node.get_attribute("_pvis").is_some();
1848    if (!cvis || has_cvis) && (!pvis || has_pvis) {
1849      return;
1850    }
1851
1852    if cvis {
1853      node.set_attribute("_cvis", "1").ok();
1854    }
1855    if pvis {
1856      node.set_attribute("_pvis", "1").ok();
1857    }
1858
1859    if qname == "ltx:XMDual" {
1860      let mut children = element_children(node);
1861      if children.len() >= 2 {
1862        if cvis {
1863          self.mark_xm_node_visibility_aux(&mut children[0], true, false);
1864        }
1865        if pvis {
1866          self.mark_xm_node_visibility_aux(&mut children[1], false, true);
1867        }
1868      }
1869    } else if qname == "ltx:XMRef" {
1870      if let Some(idref) = node.get_attribute("idref") {
1871        if let Some(target) = self.find_node_by_id(&idref) {
1872          let mut target_mut = target.clone();
1873          self.mark_xm_node_visibility_aux(&mut target_mut, cvis, pvis);
1874        } else {
1875          // Perl Post.pm:1444 — Error('expected', 'id', undef,
1876          //   "Cannot find a node with xml:id='$id'")
1877          Error!(
1878            "expected",
1879            "id",
1880            "Cannot find a node with xml:id='{}'",
1881            idref
1882          );
1883        }
1884      }
1885    } else {
1886      for mut child in element_children(node) {
1887        self.mark_xm_node_visibility_aux(&mut child, cvis, pvis);
1888      }
1889    }
1890  }
1891
1892  /// Realize an XMRef/XMDual node along a branch — Perl `realizeXMNode($node,
1893  /// $branch)` (`Post.pm` L1436-1450), the two-argument form.
1894  ///
1895  /// Unlike the branchless [`realize_xm_node`](Self::realize_xm_node) below,
1896  /// this **loops**: an `XMRef` is followed to its target, an `XMDual` is
1897  /// descended into the requested branch, and either may expose the other, so
1898  /// resolution repeats until the node is neither. A dangling `idref` reports
1899  /// the same `expected:id` error and yields `None`.
1900  pub fn realize_xm_node_branch(&self, node: &Node, branch: XMBranch) -> Option<Node> {
1901    let mut node = node.clone();
1902    loop {
1903      if self.is_qname(&node, "ltx:XMRef") {
1904        let idref = node.get_attribute("idref")?;
1905        match self.find_node_by_id(&idref) {
1906          Some(target) => node = target.clone(),
1907          None => {
1908            Error!(
1909              "expected",
1910              "id",
1911              "Cannot find a node with xml:id='{}'",
1912              idref
1913            );
1914            return None;
1915          },
1916        }
1917      } else if self.is_qname(&node, "ltx:XMDual") {
1918        // Perl `my ($content, $presentation) = element_nodes($node)` — the two
1919        // branches in that order. A malformed dual with a missing branch leaves
1920        // Perl with an undef `$node`, ending the loop; `None` says the same.
1921        let children = element_children(&node);
1922        node = children.get(branch as usize)?.clone();
1923      } else {
1924        return Some(node);
1925      }
1926    }
1927  }
1928
1929  /// Realize an XMRef node: follow the reference to get the "real" node.
1930  ///
1931  /// Port of `Post::Document::realizeXMNode`'s one-argument form (`Post.pm`
1932  /// L1451-1456) — a single `XMRef` hop, leaving an `XMDual` alone. Callers that
1933  /// need a specific branch want [`realize_xm_node_branch`](Self::realize_xm_node_branch).
1934  pub fn realize_xm_node(&self, node: &Node) -> Option<Node> {
1935    if self.is_qname(node, "ltx:XMRef") {
1936      let idref = node.get_attribute("idref")?;
1937      let realized = self.find_node_by_id(&idref).cloned();
1938      if realized.is_none() {
1939        // Perl Post.pm:1456 — Error('expected', 'id', undef,
1940        //   "Cannot find a node with xml:id='$id'")
1941        Error!(
1942          "expected",
1943          "id",
1944          "Cannot find a node with xml:id='{}'",
1945          idref
1946        );
1947      }
1948      realized
1949    } else {
1950      Some(node.clone())
1951    }
1952  }
1953
1954  // ======================================================================
1955  // Utility methods
1956
1957  /// Join a list of nodes with a conjunction.
1958  ///
1959  /// Port of `Post::Document::conjoin`.
1960  pub fn conjoin(conjunction: Conjunction, nodes: Vec<NodeData>) -> Vec<NodeData> {
1961    let n = nodes.len();
1962    if n < 2 {
1963      return nodes;
1964    }
1965
1966    let (comma, and) = match conjunction {
1967      Conjunction::Simple(s) => (s.clone(), s),
1968      Conjunction::Pair(c, a) => (c, a),
1969    };
1970
1971    let mut result = Vec::new();
1972    let mut iter = nodes.into_iter();
1973    result.push(iter.next().unwrap());
1974
1975    let mut remaining: Vec<_> = iter.collect();
1976    while remaining.len() > 1 {
1977      result.push(NodeData::Text(comma.clone()));
1978      result.push(remaining.remove(0));
1979    }
1980    result.push(NodeData::Text(and));
1981    result.push(remaining.remove(0));
1982    result
1983  }
1984
1985  /// Find the initial letter for sorting.
1986  ///
1987  /// Port of `Post::Document::initial`.
1988  pub fn initial(string: &str, force: bool) -> String {
1989    let decomposed: String = string.nfd().collect();
1990    let trimmed = decomposed.trim_start();
1991    let s = if force {
1992      trimmed.trim_start_matches(|c: char| !c.is_ascii_alphabetic())
1993    } else {
1994      trimmed
1995    };
1996    match s.chars().next() {
1997      Some(c) if c.is_ascii_alphabetic() => c.to_uppercase().to_string(),
1998      _ => "*".to_string(),
1999    }
2000  }
2001
2002  /// Trim leading/trailing whitespace text nodes from a node's children.
2003  ///
2004  /// Port of `Post::Document::trimChildNodes`.
2005  pub fn trim_child_nodes(node: &Node) -> Vec<Node> {
2006    let mut children: Vec<Node> = Vec::new();
2007    if let Some(child) = node.get_first_child() {
2008      let mut current = Some(child);
2009      while let Some(ref c) = current {
2010        children.push(c.clone());
2011        current = c.get_next_sibling();
2012      }
2013    }
2014
2015    if children.is_empty() {
2016      return children;
2017    }
2018
2019    // Trim leading whitespace
2020    if let Some(first) = children.first_mut() {
2021      if first.get_type() == Some(NodeType::TextNode) {
2022        let text = first.get_content();
2023        let trimmed = text.trim_start();
2024        if trimmed.is_empty() {
2025          children.remove(0);
2026        } else if trimmed != text {
2027          first.set_content(trimmed).ok();
2028        }
2029      }
2030    }
2031
2032    // Trim trailing whitespace
2033    if let Some(last) = children.last_mut() {
2034      if last.get_type() == Some(NodeType::TextNode) {
2035        let text = last.get_content();
2036        let trimmed = text.trim_end();
2037        if trimmed.is_empty() {
2038          children.pop();
2039        } else if trimmed != text {
2040          last.set_content(trimmed).ok();
2041        }
2042      }
2043    }
2044
2045    children
2046  }
2047
2048  /// Add a navigation reference.
2049  ///
2050  /// Port of `Post::Document::addNavigation`.
2051  pub fn add_navigation(&mut self, relation: &str, id: &str) {
2052    // Perl `Post.pm:1409` probes for the duplicate with a whole-document XPath
2053    // built by string interpolation. The memo answers the same question — see
2054    // the `nav_memo` field docs for why the XPath form cannot stand at
2055    // 16.3 M calls. `insert` returning false means the pair is already
2056    // present, which is Perl's early `return`.
2057    if self.navigation_ref_present(relation, id) {
2058      return;
2059    }
2060
2061    let ref_node = NodeData::Element {
2062      tag:        "ltx:ref".to_string(),
2063      attributes: Some(HashMap::from_iter([
2064        ("idref".to_string(), id.to_string()),
2065        ("rel".to_string(), relation.to_string()),
2066        ("show".to_string(), "toctitle".to_string()),
2067      ])),
2068      children:   vec![],
2069    };
2070
2071    match self.navigation_element() {
2072      Some(mut nav) => {
2073        self.add_nodes(&mut nav, &[ref_node]);
2074        self.record_navigation_ref(relation, id);
2075      },
2076      _ => {
2077        if let Some(mut root) = self.get_document_element() {
2078          let nav_node = NodeData::Element {
2079            tag:        "ltx:navigation".to_string(),
2080            attributes: None,
2081            children:   vec![ref_node],
2082          };
2083          self.add_nodes(&mut root, &[nav_node]);
2084          // Adopt the element just created, so the next of this page's ~406
2085          // calls does not re-walk the document looking for it.
2086          let found = self.findnode("//ltx:navigation");
2087          if let Some(memo) = self.nav_memo.as_mut() {
2088            memo.element = found;
2089          }
2090          self.record_navigation_ref(relation, id);
2091        }
2092        // No document element: nothing was added, so nothing is recorded —
2093        // a later call must be free to retry, exactly as re-running Perl's
2094        // XPath probe would.
2095      },
2096    }
2097  }
2098
2099  /// The document's `ltx:navigation` element, memoized (Perl re-runs
2100  /// `findnode('//ltx:navigation')` per call — `Post.pm:1411`).
2101  ///
2102  /// The cached handle is revalidated before reuse: a node that has been
2103  /// unlinked from the tree has no parent, and reusing it would append into a
2104  /// detached subtree that never reaches the output. That check is one FFI
2105  /// call against a full descendant-axis walk.
2106  fn navigation_element(&mut self) -> Option<Node> {
2107    if let Some(memo) = self.nav_memo.as_ref()
2108      && let Some(nav) = memo.element.as_ref()
2109      && nav.get_parent().is_some()
2110    {
2111      return Some(nav.clone());
2112    }
2113    let found = self.findnode("//ltx:navigation");
2114    if let Some(memo) = self.nav_memo.as_mut() {
2115      memo.element = found.clone();
2116    }
2117    found
2118  }
2119
2120  /// Is this `(rel, idref)` pair already under a navigation element?
2121  ///
2122  /// Stands in for Perl's per-call duplicate XPath (`Post.pm:1409`), and must
2123  /// answer identically. Three details earn their keep:
2124  ///
2125  /// * The seed reads **every** `ltx:navigation` element, because `//` in the
2126  ///   Perl probe does — while insertion targets the FIRST, because Perl's
2127  ///   `findnode` (`Post.pm:1411`) does. A page can arrive with navigation
2128  ///   already populated: `Split` strips the source's navigation and copies it
2129  ///   into each page (`split.rs:465`, `Split::add_navigation`).
2130  /// * `ltx:ref` is namespace-qualified, so a `ref` in some other namespace
2131  ///   must NOT seed the set.
2132  /// * `ltx:TOC` / `ltx:title` also live under navigation and are not refs.
2133  fn seed_navigation_memo(&mut self) {
2134    if self.nav_memo.is_some() {
2135      return;
2136    }
2137    let mut refs = FxHashSet::default();
2138    let elements = self.findnodes("//ltx:navigation");
2139    for nav in &elements {
2140      for child in nav.get_child_nodes() {
2141        if child.get_name() != "ref" {
2142          continue;
2143        }
2144        // Strict: an unnamespaced `<ref>` does not match `ltx:ref` in XPath
2145        // either, so it must not seed the set.
2146        let in_ltx = child
2147          .get_namespace()
2148          .is_some_and(|ns| ns.get_href() == LTX_NSURI);
2149        if !in_ltx {
2150          continue;
2151        }
2152        if let (Some(rel), Some(idref)) = (child.get_attribute("rel"), child.get_attribute("idref"))
2153        {
2154          refs.insert((rel, idref));
2155        }
2156      }
2157    }
2158    self.nav_memo = Some(NavigationMemo {
2159      element: elements.into_iter().next(),
2160      refs,
2161    });
2162  }
2163
2164  fn navigation_ref_present(&mut self, relation: &str, id: &str) -> bool {
2165    self.seed_navigation_memo();
2166    self
2167      .nav_memo
2168      .as_ref()
2169      .is_some_and(|memo| memo.refs.contains(&(relation.to_string(), id.to_string())))
2170  }
2171
2172  /// Record a pair only once it has actually been added to the tree.
2173  fn record_navigation_ref(&mut self, relation: &str, id: &str) {
2174    if let Some(memo) = self.nav_memo.as_mut() {
2175      memo.refs.insert((relation.to_string(), id.to_string()));
2176    }
2177  }
2178
2179  // ======================================================================
2180  // Validation
2181
2182  /// Validate the document against its declared schema.
2183  ///
2184  /// Port of `Post::Document::validate`.
2185  pub fn validate(&self) -> Result<(), String> {
2186    let rng_re = Regex::new(r#"^\s*RelaxNGSchema\s*=\s*[\"'](.*?)[\"']\s*$"#).unwrap();
2187    for pi_text in &self.processing_instructions {
2188      if let Some(cap) = rng_re.captures(pi_text) {
2189        let schema = &cap[1];
2190        Info!(
2191          "validate",
2192          "schema",
2193          "Would validate against RelaxNG schema: {}",
2194          schema
2195        );
2196        return Ok(());
2197      }
2198    }
2199    // Perl Post.pm:973 — Error('I/O', $schema, undef, "Failed to load
2200    //   RelaxNG schema $schema") when no usable schema; here we don't
2201    //   even have a path. Reporting at warn (no schema = nothing to
2202    //   validate, often a benign config) with the structured target.
2203    Warn!(
2204      "missing_file",
2205      "schema",
2206      "No schema found for document validation"
2207    );
2208    Ok(())
2209  }
2210
2211  /// Check ID consistency.
2212  ///
2213  /// Port of `Post::Document::idcheck`.
2214  pub fn idcheck(&self) {
2215    let mut doc_ids: HashMap<String, bool> = HashMap::default();
2216    let mut dups = Vec::new();
2217
2218    for node in self.findnodes("//*[@xml:id]") {
2219      if let Some(id) = get_xml_id(&node) {
2220        if doc_ids.contains_key(&id) {
2221          dups.push(id.clone());
2222        }
2223        doc_ids.insert(id, true);
2224      }
2225    }
2226
2227    let mut missing = Vec::new();
2228    for id in self.idcache.keys() {
2229      if !doc_ids.contains_key(id) {
2230        missing.push(id.clone());
2231      }
2232    }
2233
2234    if !dups.is_empty() {
2235      Warn!(
2236        "malformed",
2237        "id",
2238        "Duplicate IDs for {}: {}",
2239        self.site_relative_destination().unwrap_or_default(),
2240        dups.join(", ")
2241      );
2242    }
2243    if !missing.is_empty() {
2244      Warn!(
2245        "expected",
2246        "id",
2247        "Cached IDs not in document for {}: {}",
2248        self.site_relative_destination().unwrap_or_default(),
2249        missing.join(", ")
2250      );
2251    }
2252  }
2253
2254  // ======================================================================
2255  // Cache support
2256
2257  /// Look up a value in the persistent cache.
2258  pub fn cache_lookup(&self, key: &str) -> Option<String> { self.cache.get(key).cloned() }
2259
2260  /// Store a value in the persistent cache.
2261  pub fn cache_store(&mut self, key: &str, value: &str) {
2262    self.cache.insert(key.to_string(), value.to_string());
2263  }
2264
2265  /// Remove a value from the persistent cache.
2266  pub fn cache_remove(&mut self, key: &str) { self.cache.remove(key); }
2267}
2268
2269// ======================================================================
2270// Supporting types
2271
2272/// Options for creating a PostDocument.
2273#[derive(Debug, Default, Clone)]
2274pub struct PostDocumentOptions {
2275  pub destination:           Option<String>,
2276  pub destination_directory: Option<String>,
2277  pub site_directory:        Option<String>,
2278  pub source:                Option<String>,
2279  pub source_directory:      Option<String>,
2280  pub searchpaths:           Option<Vec<String>>,
2281  pub validate:              bool,
2282  pub nocache:               bool,
2283}
2284
2285/// Recursive representation for building XML nodes.
2286///
2287/// Port of the Perl `data = string | [$tag, {attrs}, @children]` convention.
2288#[derive(Debug, Clone)]
2289pub enum NodeData {
2290  /// A text node.
2291  Text(String),
2292  /// An element node with tag (prefix:localname), optional attributes, and children.
2293  Element {
2294    tag:        String,
2295    attributes: Option<HashMap<String, String>>,
2296    children:   Vec<NodeData>,
2297  },
2298  /// A reference to an existing XML node (will be cloned when added).
2299  XmlNode(Node),
2300}
2301
2302/// Which branch of an `ltx:XMDual` to follow — Perl's `$branch` string argument
2303/// to `realizeXMNode`, which is only ever `'content'` or `'presentation'`.
2304///
2305/// The discriminants are the child positions Perl's
2306/// `my ($content, $presentation) = element_nodes($node)` unpacks, so the enum
2307/// indexes the children directly.
2308#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2309pub enum XMBranch {
2310  /// The semantic branch — the first child.
2311  Content = 0,
2312  /// The visual branch — the second child.
2313  Presentation = 1,
2314}
2315
2316/// Conjunction for joining node lists.
2317pub enum Conjunction {
2318  /// A single separator used everywhere.
2319  Simple(String),
2320  /// (comma, and) — comma between items, 'and' before the last.
2321  Pair(String, String),
2322}
2323
2324// ======================================================================
2325// Helper functions
2326
2327/// Get element children of a node (skipping text, comments, etc.).
2328pub fn element_children(node: &Node) -> Vec<Node> {
2329  let mut result = Vec::new();
2330  if let Some(child) = node.get_first_child() {
2331    let mut current = Some(child);
2332    while let Some(ref c) = current {
2333      if c.get_type() == Some(NodeType::ElementNode) {
2334        result.push(c.clone());
2335      }
2336      current = c.get_next_sibling();
2337    }
2338  }
2339  result
2340}
2341
2342/// Iterator version of `element_children` — walks the sibling chain lazily.
2343/// Prefer this in hot paths that only need to read or filter children
2344/// without materializing a Vec. Callers that need len() or random access
2345/// still want the Vec version.
2346pub fn element_children_iter(node: &Node) -> impl Iterator<Item = Node> + use<> {
2347  let first = node.get_first_child();
2348  std::iter::successors(first, |c| c.get_next_sibling())
2349    .filter(|c| c.get_type() == Some(NodeType::ElementNode))
2350}
2351
2352/// Escape a string for XML **text content or a double-quoted attribute value**.
2353///
2354/// The one escaper for markup this crate still assembles as a string. It used to
2355/// be three byte-identical private copies: `make_bibliography.rs::xml_escape`
2356/// went away with the `.bib` string route, `manifest/epub.rs::escape_xml` went
2357/// away when the OPF moved onto the DOM, and `schema_docs.rs::html_escape` — the
2358/// dev-facing RelaxNG doc site, the last string-assembled markup here — is what
2359/// remains. Duplicated escapers drift; that is issue 386's premise, and the
2360/// reason to keep exactly one even now that it has a single caller.
2361///
2362/// **`'` is deliberately not escaped.** It is legal raw in text content, and both
2363/// callers emit double-quoted attributes, where only `"` must go. Escaping it
2364/// would change output without fixing anything.
2365///
2366/// **`latexml_core::document`'s `serialize_string`/`serialize_attr` are NOT
2367/// folded in here, and must not be.** They look like a fourth copy with a
2368/// missing `"`, but they are a correct pair: `serialize_string` escapes text
2369/// nodes and comments, and `serialize_attr` layers `"`, `\n` and `\t` on top for
2370/// attribute values. Escaping `"` in a text node would be *wrong* XML, not safer
2371/// XML.
2372pub fn escape_xml(s: &str) -> String {
2373  s.replace('&', "&amp;")
2374    .replace('<', "&lt;")
2375    .replace('>', "&gt;")
2376    .replace('"', "&quot;")
2377}
2378
2379/// Compute a relative path from `base` to `path`.
2380fn pathdiff(path: &str, base: &str) -> String {
2381  let p = Path::new(path);
2382  let b = Path::new(base);
2383  if let Ok(rel) = p.strip_prefix(b) {
2384    rel.to_string_lossy().to_string()
2385  } else {
2386    path.to_string()
2387  }
2388}
2389
2390#[cfg(test)]
2391mod tests {
2392  use super::*;
2393
2394  fn make_test_doc(xml: &str) -> PostDocument {
2395    PostDocument::new_from_string(xml, PostDocumentOptions::default()).unwrap()
2396  }
2397
2398  /// The traversal path must return EXACTLY what XPath returns — same nodes,
2399  /// same document order — for every shape it claims. Compared against
2400  /// libxml2 on a document small enough for XPath to answer reliably, so a
2401  /// grammar slip shows up as a mismatch rather than as silently different
2402  /// post-processing on large inputs.
2403  /// `add_navigation` memoizes the navigation element and the `(rel, idref)`
2404  /// pairs instead of re-running Perl's per-call XPath probe
2405  /// (`Post.pm:1409-1414`). The dedup must stay EXACTLY Perl's, including for a
2406  /// document that already carries navigation refs when it arrives — the case
2407  /// the memo has to seed rather than assume empty.
2408  #[test]
2409  fn add_navigation_dedupes_including_preexisting_refs() {
2410    let mut doc = make_test_doc(
2411      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2412         <navigation>\
2413           <ref rel='chapter' idref='Ch1' show='toctitle'/>\
2414           <title>ignored — not an ltx:ref</title>\
2415         </navigation>\
2416       </document>",
2417    );
2418
2419    doc.add_navigation("chapter", "Ch1"); // already present -> no-op
2420    doc.add_navigation("section", "S1"); // new
2421    doc.add_navigation("section", "S1"); // duplicate of the one just added
2422    doc.add_navigation("sidebar", "Ch1"); // same id, DIFFERENT rel -> distinct
2423
2424    let refs = doc.findnodes("//ltx:navigation/ltx:ref");
2425    let mut pairs: Vec<(String, String)> = refs
2426      .iter()
2427      .map(|n| {
2428        (
2429          n.get_attribute("rel").unwrap_or_default(),
2430          n.get_attribute("idref").unwrap_or_default(),
2431        )
2432      })
2433      .collect();
2434    pairs.sort();
2435    assert_eq!(pairs, vec![
2436      ("chapter".to_string(), "Ch1".to_string()),
2437      ("section".to_string(), "S1".to_string()),
2438      ("sidebar".to_string(), "Ch1".to_string()),
2439    ]);
2440    assert_eq!(
2441      doc.findnodes("//ltx:navigation").len(),
2442      1,
2443      "the existing navigation element must be reused, not duplicated"
2444    );
2445  }
2446
2447  /// The same dedup, but starting from a document with NO navigation element:
2448  /// the first call must create it and the memo must then adopt what it created.
2449  /// D1: the Perl probe is `//ltx:navigation/ltx:ref[…]` — `//`, so refs under
2450  /// a SECOND navigation element count as present too. `Split` really does put
2451  /// navigation into pages (`split.rs:465`), so this shape is reachable.
2452  /// Insertion still targets the FIRST element, as Perl's `findnode` does.
2453  #[test]
2454  fn add_navigation_sees_refs_under_every_navigation_element() {
2455    let mut doc = make_test_doc(
2456      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2457         <navigation><ref rel='chapter' idref='Ch1'/></navigation>\
2458         <section><navigation><ref rel='section' idref='S9'/></navigation></section>\
2459       </document>",
2460    );
2461
2462    doc.add_navigation("section", "S9"); // present under the SECOND element
2463    doc.add_navigation("chapter", "Ch1"); // present under the FIRST
2464
2465    assert_eq!(
2466      doc.findnodes("//ltx:navigation/ltx:ref").len(),
2467      2,
2468      "neither pair may be re-added; `//` spans both navigation elements"
2469    );
2470
2471    doc.add_navigation("appendix", "A1"); // genuinely new
2472    let first_nav_refs = doc.findnodes("//ltx:navigation").first().map(|n| {
2473      n.get_child_nodes()
2474        .iter()
2475        .filter(|c| c.get_name() == "ref")
2476        .count()
2477    });
2478    assert_eq!(
2479      first_nav_refs,
2480      Some(2),
2481      "a new ref lands under the FIRST navigation element (Perl's findnode)"
2482    );
2483  }
2484
2485  /// D2: `ltx:ref` is namespace-qualified. A `<ref>` in a foreign namespace
2486  /// does not match the Perl probe, so it must not suppress a real insert.
2487  #[test]
2488  fn add_navigation_ignores_a_foreign_namespace_ref_when_seeding() {
2489    let mut doc = make_test_doc(
2490      "<document xmlns='http://dlmf.nist.gov/LaTeXML' xmlns:other='http://example.org/other'>\
2491         <navigation><other:ref rel='chapter' idref='Ch1'/></navigation>\
2492       </document>",
2493    );
2494
2495    doc.add_navigation("chapter", "Ch1");
2496
2497    assert_eq!(
2498      doc.findnodes("//ltx:navigation/ltx:ref").len(),
2499      1,
2500      "the foreign-namespace ref is not an ltx:ref, so the real one must be added"
2501    );
2502  }
2503
2504  /// The memo must agree with the ORIGINAL XPath probe on every pair, for a
2505  /// document that mixes pre-existing refs, foreign namespaces, and non-ref
2506  /// children — this is the differential the refactor has to survive.
2507  #[test]
2508  fn navigation_memo_agrees_with_the_original_xpath_probe() {
2509    let mut doc = make_test_doc(
2510      "<document xmlns='http://dlmf.nist.gov/LaTeXML' xmlns:other='http://example.org/other'>\
2511         <navigation>\
2512           <ref rel='chapter' idref='Ch1'/>\
2513           <title>t</title>\
2514           <other:ref rel='section' idref='S1'/>\
2515         </navigation>\
2516       </document>",
2517    );
2518
2519    for (rel, id) in [
2520      ("chapter", "Ch1"), // present -> XPath finds it
2521      ("section", "S1"),  // only in a FOREIGN ns -> XPath does NOT find it
2522      ("title", "t"),     // not a ref at all
2523      ("section", "S2"),  // absent
2524    ] {
2525      // The probe Perl runs, verbatim (Post.pm:1409).
2526      let probe = format!("//ltx:navigation/ltx:ref[@rel='{}'][@idref='{}']", rel, id);
2527      let xpath_says_present = doc.findnode(&probe).is_some();
2528      let memo_says_present = doc.navigation_ref_present(rel, id);
2529      assert_eq!(
2530        memo_says_present, xpath_says_present,
2531        "memo and XPath disagree about ({rel}, {id})"
2532      );
2533    }
2534  }
2535
2536  #[test]
2537  fn add_navigation_creates_then_reuses_the_navigation_element() {
2538    let mut doc = make_test_doc("<document xmlns='http://dlmf.nist.gov/LaTeXML'><p/></document>");
2539
2540    doc.add_navigation("section", "S1");
2541    doc.add_navigation("section", "S2");
2542    doc.add_navigation("section", "S1"); // duplicate
2543
2544    assert_eq!(
2545      doc.findnodes("//ltx:navigation").len(),
2546      1,
2547      "exactly one navigation element must be created"
2548    );
2549    assert_eq!(doc.findnodes("//ltx:navigation/ltx:ref").len(), 2);
2550  }
2551
2552  #[test]
2553  fn walk_union_agrees_with_xpath_on_every_supported_shape() {
2554    let doc = make_test_doc(
2555      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2556         <ref href='u1'/>\
2557         <ref href='u2' idref='i1'/>\
2558         <ref labelref='L1'/>\
2559         <graphics/>\
2560         <graphics imagesrc='a.png'/>\
2561         <Math id='m1'><XMath><Math id='inner'/></XMath></Math>\
2562         <index/>\
2563         <index><indexlist/></index>\
2564         <glossary/>\
2565         <p idref='i2'/>\
2566         <XMDual _cvis='1'/>\
2567         <XMDual _pvis='1'/>\
2568         <XMDual _cvis='1' _pvis='1'/>\
2569         <XMDual/>\
2570       </document>",
2571    );
2572    for xpath in [
2573      "//*[@idref]",
2574      "//*[@labelref]",
2575      "//ltx:ref[@href and not(@idref) and not(@labelref)]",
2576      "//ltx:graphics[not(@imagesrc)]",
2577      "//ltx:Math[not(ancestor::ltx:Math)]",
2578      "//ltx:index[not(ltx:indexlist)] | //ltx:glossary[not(ltx:glossarylist)]",
2579      "//ltx:ref",
2580      // Disjunction: post asks this one on every document (XMDual visibility).
2581      "//*[@_cvis or @_pvis]",
2582      "//ltx:ref[@href or @idref]",
2583    ] {
2584      let arms = parse_walk_union(xpath)
2585        .unwrap_or_else(|| panic!("`{xpath}` must be inside the walk grammar"));
2586      let mut walked = Vec::new();
2587      collect_walk_matches(&doc.get_document_element().unwrap(), &arms, &mut walked);
2588      // The XPath side deliberately goes through the same public entry point,
2589      // which routes recognised shapes to the walk — so compare against
2590      // libxml2 directly instead.
2591      let ctx = libxml::xpath::Context::new(&doc.document).expect("ctx");
2592      ctx.register_namespace("ltx", LTX_NSURI).expect("ns");
2593      let root = doc.get_document_element().unwrap();
2594      let expected: Vec<String> = ctx
2595        .node_evaluate(xpath, &root)
2596        .expect("xpath evaluates on a small doc")
2597        .get_nodes_as_vec()
2598        .iter()
2599        .map(|n| format!("{}#{:?}", n.get_name(), n.get_attribute("id")))
2600        .collect();
2601      let got: Vec<String> = walked
2602        .iter()
2603        .map(|n| format!("{}#{:?}", n.get_name(), n.get_attribute("id")))
2604        .collect();
2605      assert_eq!(got, expected, "walk disagrees with XPath for `{xpath}`");
2606    }
2607  }
2608
2609  /// A shape outside the grammar must NOT be approximated — it has to fall
2610  /// through to real XPath, or a query would silently answer wrongly.
2611  #[test]
2612  fn unsupported_shapes_are_not_claimed_by_the_walk() {
2613    for xpath in [
2614      "//ltx:section/ltx:title",            // a path, not a //NAME test
2615      "//ltx:ref[position()=1]",            // function predicate
2616      "descendant::ltx:navigation",         // relative axis
2617      "//svg:svg",                          // non-ltx prefix
2618      "//ltx:Math[not(ancestor::svg:svg)]", // non-ltx in the atom
2619      "//ltx:ref[(@a or @b) and @c]",       // parenthesised precedence
2620    ] {
2621      assert!(
2622        parse_walk_union(xpath).is_none(),
2623        "`{xpath}` must fall through to XPath, not be walked"
2624      );
2625    }
2626  }
2627
2628  #[test]
2629  fn test_new_from_string() {
2630    let doc = make_test_doc("<document xmlns='http://dlmf.nist.gov/LaTeXML'/>");
2631    assert!(doc.get_document_element().is_some());
2632  }
2633
2634  #[test]
2635  fn test_findnodes() {
2636    let doc = make_test_doc(
2637      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2638         <section xml:id='s1'/>\
2639         <section xml:id='s2'/>\
2640       </document>",
2641    );
2642    let sections = doc.findnodes("//ltx:section");
2643    assert_eq!(sections.len(), 2);
2644  }
2645
2646  /// `findnodes` with no context node must resolve RELATIVE location paths
2647  /// (`descendant::…`, `.//…`) against the tree — matching XML::LibXML's
2648  /// `$doc->findnodes` (which evaluates from the document node) — not silently
2649  /// return nothing. Regression guard for the split-page resource/PI copy (#341):
2650  /// the fix binds the root element as the context node. Before-root PIs still
2651  /// need the absolute `//processing-instruction()` form (documented in
2652  /// `findnodes_at`), verified here too.
2653  #[test]
2654  fn findnodes_resolves_relative_axes_without_context_node() {
2655    let doc = make_test_doc(
2656      "<?latexml class='book'?>\
2657       <document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2658         <resource src='a.css' type='text/css'/>\
2659         <section xml:id='s1'/>\
2660       </document>",
2661    );
2662    assert_eq!(doc.findnodes("//ltx:resource").len(), 1, "absolute");
2663    assert_eq!(
2664      doc.findnodes("descendant::ltx:resource").len(),
2665      1,
2666      "descendant:: axis must resolve without an explicit context node"
2667    );
2668    assert_eq!(
2669      doc.findnodes(".//ltx:resource").len(),
2670      1,
2671      ".// axis must resolve without an explicit context node"
2672    );
2673    // Before-root PIs: absolute form finds them, relative-from-root does not.
2674    assert_eq!(
2675      doc.findnodes("//processing-instruction('latexml')").len(),
2676      1,
2677      "absolute PI query finds the before-root <?latexml?>"
2678    );
2679  }
2680
2681  /// The exact `--splitat=section` union `make_splitpaths` emits (the shape the
2682  /// limit-safe `find_split_pages` walk must reproduce).
2683  const SECTION_UNION: &str = "//ltx:section | \
2684    //ltx:bibliography[preceding-sibling::ltx:section or parent::ltx:part or parent::ltx:chapter] | \
2685    //ltx:appendix[preceding-sibling::ltx:section or parent::ltx:part or parent::ltx:chapter] | \
2686    //ltx:index[preceding-sibling::ltx:section or parent::ltx:part or parent::ltx:chapter] | \
2687    //ltx:part | \
2688    //ltx:bibliography[preceding-sibling::ltx:part] | \
2689    //ltx:appendix[preceding-sibling::ltx:part] | \
2690    //ltx:index[preceding-sibling::ltx:part] | \
2691    //ltx:chapter | \
2692    //ltx:bibliography[preceding-sibling::ltx:chapter or parent::ltx:part] | \
2693    //ltx:appendix[preceding-sibling::ltx:chapter or parent::ltx:part] | \
2694    //ltx:index[preceding-sibling::ltx:chapter or parent::ltx:part]";
2695
2696  fn split_doc() -> PostDocument {
2697    make_test_doc(
2698      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2699         <part xml:id='P1'>\
2700           <chapter xml:id='C1'>\
2701             <section xml:id='S1'/>\
2702             <section xml:id='S2'/>\
2703             <index xml:id='I1'/>\
2704           </chapter>\
2705           <bibliography xml:id='B1'/>\
2706         </part>\
2707         <section xml:id='S3'/>\
2708         <index xml:id='I2'/>\
2709       </document>",
2710    )
2711  }
2712
2713  /// The limit-safe DOM walk must select exactly the nodes (and order) the raw
2714  /// XPath union selects on a small document where XPath is limit-safe.
2715  #[test]
2716  fn test_find_split_pages_matches_xpath() {
2717    let doc = split_doc();
2718    let ids = |nodes: Vec<Node>| -> Vec<String> { nodes.iter().filter_map(get_xml_id).collect() };
2719    let via_walk = ids(doc.find_split_pages(SECTION_UNION));
2720    let via_xpath = ids(doc.findnodes(SECTION_UNION));
2721    assert_eq!(
2722      via_walk,
2723      vec!["P1", "C1", "S1", "S2", "I1", "B1", "S3", "I2"],
2724      "walk selected the wrong pages / order"
2725    );
2726    assert_eq!(via_walk, via_xpath, "walk diverged from XPath union");
2727  }
2728
2729  /// A union outside the recognized grammar falls back to raw XPath (unchanged
2730  /// behavior for custom `--splitpaths`).
2731  #[test]
2732  fn test_find_split_pages_fallback() {
2733    let doc = split_doc();
2734    // `descendant::` is not in the make_splitpaths grammar → fallback path.
2735    let via = doc.find_split_pages("//ltx:chapter/descendant::ltx:section");
2736    let ids: Vec<String> = via.iter().filter_map(get_xml_id).collect();
2737    assert_eq!(ids, vec!["S1", "S2"]);
2738  }
2739
2740  /// The id scan must populate the idcache for every `xml:id` (the walk replaces
2741  /// the `//*[@xml:id]` query that overflows libxml2 on huge documents).
2742  #[test]
2743  fn test_scan_ids_populates_idcache() {
2744    let doc = split_doc();
2745    for id in ["P1", "C1", "S1", "S2", "I1", "B1", "S3", "I2"] {
2746      assert!(
2747        doc.find_node_by_id(id).is_some(),
2748        "missing id {id} in idcache"
2749      );
2750    }
2751    assert!(doc.find_node_by_id("nope").is_none());
2752  }
2753
2754  /// A document-level `<?latexml searchpaths=…?>` PI (a sibling of the root
2755  /// element) must still be collected by the walk and its searchpaths applied.
2756  #[test]
2757  fn test_scan_collects_doclevel_pi_searchpaths() {
2758    let doc = make_test_doc(
2759      "<?latexml searchpaths=\"alpha,beta\"?>\
2760       <document xmlns='http://dlmf.nist.gov/LaTeXML'><section xml:id='s1'/></document>",
2761    );
2762    let paths = doc.get_search_paths();
2763    assert!(paths.iter().any(|p| p == "alpha"), "searchpaths: {paths:?}");
2764    assert!(paths.iter().any(|p| p == "beta"), "searchpaths: {paths:?}");
2765  }
2766
2767  #[test]
2768  fn test_uniquify_id() {
2769    let doc = make_test_doc(
2770      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2771         <p xml:id='p1'/>\
2772       </document>",
2773    );
2774    let mut doc = doc;
2775    // First call reserves a unique id based on "p1"
2776    let id1 = doc.uniquify_id("p1", None);
2777    // Second call with same base must produce a different id
2778    let id2 = doc.uniquify_id("p1", None);
2779    assert_ne!(id1, id2);
2780    // Both should start with p1
2781    assert!(id1.starts_with("p1"));
2782    assert!(id2.starts_with("p1"));
2783  }
2784
2785  #[test]
2786  fn test_initial() {
2787    assert_eq!(PostDocument::initial("Hello", false), "H");
2788    assert_eq!(PostDocument::initial("  world", false), "W");
2789    assert_eq!(PostDocument::initial("123abc", true), "A");
2790    assert_eq!(PostDocument::initial("!@#", false), "*");
2791    assert_eq!(PostDocument::initial("\u{00E9}cole", false), "E"); // é NFD-decomposes to e + combining accent
2792  }
2793
2794  #[test]
2795  fn test_add_class() {
2796    let doc = make_test_doc(
2797      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
2798         <p xml:id='p1'/>\
2799       </document>",
2800    );
2801    let mut node = doc.findnode("//ltx:p").unwrap();
2802    PostDocument::add_class(&mut node, "foo bar");
2803    let class = node.get_attribute("class").unwrap();
2804    assert!(class.contains("bar"));
2805    assert!(class.contains("foo"));
2806
2807    // Adding again should not duplicate
2808    PostDocument::add_class(&mut node, "foo");
2809    let class = node.get_attribute("class").unwrap();
2810    let count = class.matches("foo").count();
2811    assert_eq!(count, 1);
2812  }
2813}