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