Skip to main content

latexml_post/
stream_split.rs

1//! Streaming split: partition a huge core XML **file** into per-page spill
2//! files without ever building the whole-document DOM.
3//!
4//! The DOM `Split` processor ([`crate::split::Split`], port of Perl
5//! `Post::Split`) needs the entire document parsed first — measured at ~16 GB
6//! for a 614 MB core XML, and past what a 32 GB host can parse at all for the
7//! 131 MB book witness's 2.68 GB core XML (OOM before Split, zero pages
8//! written; laptop UAT 2026-07-31). This module is the
9//! `STREAMING_POST_DESIGN_2026-07-06.md` §3 front-end: a
10//! [`libxml::reader::TextReader`] pull-parse over the file, materializing one
11//! *non-page* subtree at a time, assembling each page's XML as text and
12//! spilling it the moment the page closes. Peak memory is the open ancestor
13//! chain plus one content subtree.
14//!
15//! # Fidelity contract
16//!
17//! The spill files must re-parse into the same per-page DOMs the whole-DOM
18//! pipeline (parse → `Split::process` → per-page spill) produces — the parity
19//! gate is byte-equality of the final rendered pages across the two paths
20//! (guard: `latexml_oxide/tests/118_streaming_split_parity.rs`). Every quirk
21//! of `Split::process_pages` is replicated deliberately:
22//!
23//! * **Run adjacency.** A TOC (`<ltx:TOC><ltx:toclist class="ltx_toclist_X">`)
24//!   is emitted per maximal run of *adjacent* page siblings; ANY intervening
25//!   sibling node — whitespace text included — breaks a run, exactly as the
26//!   `entries[0].node == removed[0]` check does. (`ltx:navigation` siblings do
27//!   NOT break runs: the DOM path excises them before any page surgery.)
28//! * **TOC suppression probe.** A run's TOC is suppressed iff an
29//!   `ltx:TOC[@lists='toc']` (exact match — generated TOCs carry no `lists`)
30//!   already occurs among the parent's descendants *at flush time*, i.e. in
31//!   content preceding the run.
32//! * **`inlist="toc"` propagation** is per *tree level* (all page children of
33//!   one page, across different DOM parents), with the DOM path's substring
34//!   semantics (`inlist.contains("toc")`). It needs lookahead, so the
35//!   attribute is patched into already-written spill files afterwards
36//!   (`Splitter::patch_inlist_toc`).
37//! * **`new_document` template copies**: every page gets the `<?latexml …?>`
38//!   PIs, the document's `ltx:resource` elements, the root's direct
39//!   `ltx:date` children (only when the page has no direct `ltx:date`), the
40//!   root `class` merged into its own — in exactly that order — then the
41//!   saved `ltx:navigation` elements.
42//! * **Inherited attributes** (`xml:lang`, `backgroundcolor`): nearest
43//!   ancestor-or-self value, copied onto each page root.
44//! * **Naming** ports `Split::{presort,prename,get_page_name}` including the
45//!   `FOO{n}` unnamed-page counter and its name-level-then-descend ordering.
46//! * The root page gets `xml:id="TEMPORARY_DOCUMENT_ID"` when it has no id
47//!   (the Writer removes it later), and pre-order spill order is the DOM
48//!   path's docs order (root first, each page before its descendants).
49//!
50//! # Wrapper descent
51//!
52//! A non-page subtree that *contains* page matches (e.g. a back-matter
53//! wrapper holding `ltx:appendix` pages) or `ltx:navigation` elements cannot
54//! be bulk-copied: it is expanded to an owned mini-document
55//! ([`TextReader::expand_to_document`]) and the same split surgery runs on
56//! that DOM — page matches are pre-collected on the intact subtree (the DOM
57//! path evaluates its predicates before any surgery), nested pages are
58//! unlinked and written, TOC elements are inserted in place, and the
59//! remaining shell is serialized into the enclosing page. Detection is a
60//! conservative substring probe (`Splitter::needs_dom_descent`) —
61//! over-triggering costs one expand+copy, never correctness.
62//!
63//! # Out of scope (fail loud, not wrong)
64//!
65//! * Documents whose `ltx` namespace mapping cannot be resolved from the
66//!   declaration stack error out with a pointer to
67//!   `LATEXML_POST_STREAM_SPLIT=0` (the whole-DOM path).
68//! * `ltx:resource` elements / `<?latexml?>` PIs first appearing *after* a
69//!   page has been written cannot retroactively reach it; a `Warn` flags the
70//!   (never observed in practice) case.
71
72use std::{
73  io::Write,
74  path::{Path, PathBuf},
75};
76
77use libxml::{
78  reader::{ReaderEvent, TextReader},
79  tree::{Document, Namespace, Node, NodeType},
80};
81use rustc_hash::{FxHashMap as HashMap, FxHashSet};
82
83use crate::{
84  document::{LTX_NSURI, SplitArm, SplitCond, collect_split_pages, is_ltx, parse_split_union},
85  split::SplitNaming,
86};
87
88/// One spilled page, in pre-order (index 0 is the root page).
89pub struct StreamedSplitPage {
90  /// Spill file holding the page's XML.
91  pub path:        PathBuf,
92  /// The page's destination pathname (`Split::get_page_name` result; the
93  /// original `--dest` for the root page).
94  pub destination: String,
95}
96
97/// Result of a successful streaming split.
98pub struct StreamSplitOutcome {
99  /// Pages in pre-order; `[0]` is the root page.
100  pub pages:       Vec<StreamedSplitPage>,
101  /// Bodies of every `<?latexml …?>` PI (for the ar5iv literal-intent sniff).
102  pub latexml_pis: Vec<String>,
103  /// Concatenated serializations of every `ltx:picture` subtree, ready for
104  /// the driver's `extract_svg_fragments`.
105  pub picture_xml: String,
106}
107
108/// Can this `--splitpath` union be evaluated by the streaming split? (The
109/// `make_splitpaths` grammar parses; a custom hand-written XPath may not.)
110/// The driver gates on this so a genuine mid-stream failure can be told apart
111/// from "not applicable".
112pub fn supports_union(union_xpath: &str) -> bool { parse_split_union(union_xpath).is_some() }
113
114/// Split a core-XML file into per-page spill files, streaming.
115///
116/// Returns `Ok(None)` when the union selected no pages (the "\[not split\]"
117/// case) — the caller should use the whole-DOM pipeline. `Err` means the
118/// stream could not be processed faithfully (malformed XML, unresolvable
119/// namespace shape, I/O failure); the caller decides whether to fall back.
120pub fn stream_split(
121  source_path: &str,
122  union_xpath: &str,
123  naming: SplitNaming,
124  destination: Option<&str>,
125  spill_dir: &Path,
126) -> Result<Option<StreamSplitOutcome>, String> {
127  let arms = parse_split_union(union_xpath)
128    .ok_or_else(|| format!("split union not streamable: {union_xpath}"))?;
129  // Leniency flags as in `XmlParser::default().parse_file` (recover + noerror
130  // + nowarning), PLUS `huge`: without XML_PARSE_HUGE, libxml2's hard limits
131  // corrupt a multi-GB parse long before any real malformation — measured on
132  // the 131 MB witness's 2.68 GB core XML, the per-document dictionary cap
133  // poisons the ID table from ~1.47 GB on (237,732 bogus "ID X already
134  // defined" reports for ids that each occur exactly once) and the parse
135  // dies outright at ~1.71 GB ("outer_xml failed mid-stream", same byte in
136  // every run). `xmllint --stream` reproduces both; `--huge` clears both.
137  const OPTIONS: i32 = 1 /* recover */ + 32 /* noerror */ + 64 /* nowarning */
138    + 524_288 /* huge */;
139  let reader = TextReader::from_file(source_path, OPTIONS)
140    .map_err(|()| format!("cannot open '{source_path}' for streaming"))?;
141  let mut splitter = Splitter::new(arms, naming, destination, spill_dir);
142  splitter.run(reader)?;
143  if splitter.metas.len() <= 1 {
144    // No page matched: mirror the DOM path's `[not split]` outcome and let
145    // the caller run the ordinary pipeline (the root spill alone would be a
146    // needless re-serialization of the whole document).
147    Info!("split", "result", "[not split]");
148    for meta in &splitter.metas {
149      let _ = std::fs::remove_file(&meta.file);
150    }
151    return Ok(None);
152  }
153  splitter.prename();
154  splitter.patch_inlist_toc()?;
155  let n = splitter.metas.len();
156  Info!("split", "result", " [Split into {} pages]", n);
157  let Splitter {
158    metas,
159    latexml_pis,
160    picture_xml,
161    ..
162  } = splitter;
163  let pages = metas
164    .into_iter()
165    .map(|m| StreamedSplitPage {
166      path:        m.file,
167      destination: m.name,
168    })
169    .collect();
170  Ok(Some(StreamSplitOutcome {
171    pages,
172    latexml_pis,
173    picture_xml,
174  }))
175}
176
177/// Metadata for one page, mirroring `Split`'s `PageEntry` plus what the
178/// deferred naming/patching passes need.
179struct PageMeta {
180  /// Spill file (named by pre-order index at creation).
181  file:           PathBuf,
182  /// Tree children (pages whose nearest enclosing page is this one), in
183  /// document order.
184  children:       Vec<usize>,
185  localname:      String,
186  xml_id:         Option<String>,
187  labels:         Option<String>,
188  inlist:         Option<String>,
189  /// The root `class` was *appended* as a new attribute (rather than merged
190  /// into an existing one) — the `inlist="toc"` patch must insert *before*
191  /// it to reproduce the DOM path's attribute order (inherit → inlist →
192  /// class).
193  class_appended: bool,
194  /// Destination pathname; filled by [`Splitter::prename`].
195  name:           String,
196}
197
198/// One open level of the stream: the root document element or an open page.
199/// (Wrappers never open a reader level — they are handled wholesale by the
200/// DOM descent.)
201struct Level {
202  kind:            LevelKind,
203  localname:       String,
204  ltx:             bool,
205  /// Serialized children accumulated so far (after the open tag).
206  content:         String,
207  /// `ltx:` element-children localnames seen so far — the streaming
208  /// evaluation of the `preceding-sibling::ltx:NAME` split predicate.
209  seen_ltx:        FxHashSet<String>,
210  /// The `xml:id`s of the current adjacent page run (a tocentry each).
211  run_toc:         Vec<String>,
212  run_active:      bool,
213  /// An `ltx:TOC[@lists='toc']` occurs among this element's descendants
214  /// streamed so far (the TOC-suppression probe).
215  has_lists_toc:   bool,
216  /// A direct `ltx:date` child was appended (suppresses the date copy).
217  has_direct_date: bool,
218  /// `(qname, value)` attributes from the source, document order.
219  attrs:           Vec<(String, String)>,
220  /// Effective inherited `xml:lang` / `backgroundcolor` (self-or-ancestor).
221  lang:            Option<String>,
222  bg:              Option<String>,
223  /// prefix → namespace-URI declarations introduced ON this element
224  /// (`""` = default).
225  ns_decls:        Vec<(String, String)>,
226}
227
228enum LevelKind {
229  Root,
230  /// Index into [`Splitter::metas`].
231  Page(usize),
232}
233
234struct Splitter {
235  arms:               Vec<SplitArm>,
236  naming:             SplitNaming,
237  root_destination:   String,
238  spill_dir:          PathBuf,
239  levels:             Vec<Level>,
240  metas:              Vec<PageMeta>,
241  /// The root spill's prolog: pre-root PIs/comments, reconstructed verbatim.
242  root_prolog:        String,
243  /// Root page trailing misc (post-root comments/PIs).
244  root_tail:          String,
245  /// `<?latexml …?>` bodies, document order (ar5iv sniff + page templates).
246  latexml_pis:        Vec<String>,
247  /// Serialized `ltx:resource` elements (page template).
248  resources_xml:      Vec<String>,
249  /// Serialized root-direct `ltx:date` elements (page template).
250  dates_xml:          Vec<String>,
251  /// Serialized `ltx:navigation` elements, excised from content.
252  navs_xml:           Vec<String>,
253  /// Root element `class` attribute (merged into every page).
254  root_class:         Option<String>,
255  /// Concatenated `ltx:picture` serializations for SVG extraction.
256  picture_xml:        String,
257  first_page_spilled: bool,
258  warned_late:        bool,
259  unnamed_counter:    u32,
260}
261
262impl Splitter {
263  fn new(
264    arms: Vec<SplitArm>,
265    naming: SplitNaming,
266    destination: Option<&str>,
267    spill_dir: &Path,
268  ) -> Self {
269    Splitter {
270      arms,
271      naming,
272      root_destination: destination.unwrap_or("").to_string(),
273      spill_dir: spill_dir.to_path_buf(),
274      levels: Vec::new(),
275      metas: Vec::new(),
276      root_prolog: String::new(),
277      root_tail: String::new(),
278      latexml_pis: Vec::new(),
279      resources_xml: Vec::new(),
280      dates_xml: Vec::new(),
281      navs_xml: Vec::new(),
282      root_class: None,
283      picture_xml: String::new(),
284      first_page_spilled: false,
285      warned_late: false,
286      unnamed_counter: 0,
287    }
288  }
289
290  // ====================================================================
291  // Reader-level pump
292
293  fn run(&mut self, mut reader: TextReader) -> Result<(), String> {
294    let mut advanced = reader.read().map_err(|()| "XML parse error".to_string())?;
295    while advanced {
296      match reader.event() {
297        ReaderEvent::Element => {
298          let localname = reader.local_name().unwrap_or_default();
299          let ns = reader.namespace_uri();
300          let ltx = ns.as_deref() == Some(LTX_NSURI);
301          let empty = reader.is_empty_element();
302          if self.levels.is_empty() {
303            if !self.metas.is_empty() {
304              // A second top-level element (recover-mode oddity): opening a
305              // "root" again would clobber the root spill's slot. Fail loud.
306              return Err("multiple root elements in stream".to_string());
307            }
308            // The root element (a root matching a split arm is NOT a page:
309            // the DOM path filters pages to those with a grandparent).
310            let attrs = reader.attributes_qname();
311            self.open_root(localname, ltx, attrs);
312            if empty {
313              self.close_top()?;
314            }
315          } else if ltx && self.is_page_here(&localname) {
316            let attrs = reader.attributes_qname();
317            self.open_page(localname, attrs);
318            if empty {
319              self.close_top()?;
320            }
321          } else {
322            self.top().seen_ltx_insert(ltx, &localname);
323            let outer = reader
324              .outer_xml()
325              .ok_or_else(|| "outer_xml failed mid-stream".to_string())?;
326            if ltx && localname == "navigation" {
327              // Excised BEFORE page surgery in the DOM path — deliberately
328              // does not break a page run.
329              self.navs_xml.push(outer);
330            } else if self.needs_dom_descent(&outer) {
331              let mut minidoc = reader
332                .expand_to_document()
333                .ok_or_else(|| "expand_to_document failed mid-stream".to_string())?;
334              self.descend_wrapper(&mut minidoc)?;
335            } else {
336              self.append_bulk(&outer, &localname, ltx);
337            }
338            // Skip the subtree; the reader is then positioned on the next
339            // event, so bypass the trailing read().
340            advanced = reader
341              .read_next()
342              .map_err(|()| "XML parse error".to_string())?;
343            continue;
344          }
345        },
346        ReaderEvent::EndElement => {
347          self.close_top()?;
348        },
349        ReaderEvent::Text
350        | ReaderEvent::SignificantWhitespace
351        | ReaderEvent::Whitespace
352        | ReaderEvent::CData => {
353          // CDATA is normalized to an escaped text node (identical parsed
354          // content; core XML carries no CDATA).
355          if !self.levels.is_empty() {
356            let text = reader.value().unwrap_or_default();
357            self.flush_run();
358            self.top().content.push_str(&text_escape(&text));
359          }
360          // Pre/post-root whitespace is layout-only; dropped.
361        },
362        ReaderEvent::Comment => {
363          let text = reader.value().unwrap_or_default();
364          let serialized = format!("<!--{text}-->");
365          self.append_misc(serialized);
366        },
367        ReaderEvent::ProcessingInstruction => {
368          let target = reader.local_name().unwrap_or_default();
369          let body = reader.value().unwrap_or_default();
370          let serialized = if body.is_empty() {
371            format!("<?{target}?>")
372          } else {
373            format!("<?{target} {body}?>")
374          };
375          if target == "latexml" {
376            self.template_pi(body);
377          }
378          self.append_misc(serialized);
379        },
380        ReaderEvent::EntityReference => {
381          return Err("unexpected unresolved entity reference in stream".to_string());
382        },
383        _ => {},
384      }
385      advanced = reader.read().map_err(|()| "XML parse error".to_string())?;
386    }
387    if !self.levels.is_empty() {
388      return Err("premature end of input (unclosed elements)".to_string());
389    }
390    if self.metas.is_empty() {
391      return Err("no root element found".to_string());
392    }
393    Ok(())
394  }
395
396  /// A comment/PI: into the current level's content, the pre-root prolog, or
397  /// the post-root tail.
398  fn append_misc(&mut self, serialized: String) {
399    if self.levels.is_empty() {
400      if self.metas.is_empty() {
401        self.root_prolog.push_str(&serialized);
402        self.root_prolog.push('\n');
403      } else {
404        self.root_tail.push_str(&serialized);
405        self.root_tail.push('\n');
406      }
407    } else {
408      self.flush_run();
409      self.top().content.push_str(&serialized);
410    }
411  }
412
413  /// Record a `<?latexml …?>` PI body for the page template + ar5iv sniff,
414  /// warning once if it arrives after a page has already been written.
415  fn template_pi(&mut self, body: String) {
416    self.warn_if_late("a <?latexml?> PI");
417    self.latexml_pis.push(body);
418  }
419
420  fn warn_if_late(&mut self, what: &str) {
421    if self.first_page_spilled && !self.warned_late {
422      self.warned_late = true;
423      Warn!(
424        "split",
425        "stream",
426        "{} appeared after the first page was written; already-staged pages do not carry it",
427        what
428      );
429    }
430  }
431
432  // ====================================================================
433  // Level operations
434
435  fn top(&mut self) -> &mut Level {
436    self
437      .levels
438      .last_mut()
439      .expect("level stack must be non-empty")
440  }
441
442  /// Streaming evaluation of the split union for an `ltx:` element opening
443  /// as a direct child of the current top level. Sibling/parent state is the
444  /// *intact* document's (extracted pages remain in `seen_ltx`), matching
445  /// the DOM path's evaluate-before-surgery order.
446  fn is_page_here(&self, localname: &str) -> bool {
447    let parent = self.levels.last().expect("checked non-empty");
448    self.arms.iter().any(|arm| {
449      arm.element == localname
450        && (arm.any_of.is_empty()
451          || arm.any_of.iter().any(|cond| match cond {
452            SplitCond::PrecedingSibling(name) => parent.seen_ltx.contains(name),
453            SplitCond::Parent(name) => parent.ltx && parent.localname == *name,
454          }))
455    })
456  }
457
458  fn open_root(&mut self, localname: String, ltx: bool, attrs: Vec<(String, String)>) {
459    self.root_class = attr_value(&attrs, "class");
460    let lang = attr_value(&attrs, "xml:lang");
461    let bg = attr_value(&attrs, "backgroundcolor");
462    let ns_decls = decl_attrs(&attrs);
463    self.metas.push(PageMeta {
464      file:           self.spill_dir.join("page-0000000.xml"),
465      children:       Vec::new(),
466      localname:      localname.clone(),
467      xml_id:         attr_value(&attrs, "xml:id"),
468      labels:         attr_value(&attrs, "labels"),
469      inlist:         attr_value(&attrs, "inlist"),
470      class_appended: false,
471      name:           self.root_destination.clone(),
472    });
473    self.levels.push(Level {
474      kind: LevelKind::Root,
475      localname,
476      ltx,
477      content: String::new(),
478      seen_ltx: FxHashSet::default(),
479      run_toc: Vec::new(),
480      run_active: false,
481      has_lists_toc: false,
482      has_direct_date: false,
483      attrs,
484      lang,
485      bg,
486      ns_decls,
487    });
488  }
489
490  fn open_page(&mut self, localname: String, attrs: Vec<(String, String)>) {
491    let parent_level = self.levels.last().expect("page under an open level");
492    let parent_meta = match parent_level.kind {
493      LevelKind::Root => 0,
494      LevelKind::Page(i) => i,
495    };
496    let lang = attr_value(&attrs, "xml:lang").or_else(|| parent_level.lang.clone());
497    let bg = attr_value(&attrs, "backgroundcolor").or_else(|| parent_level.bg.clone());
498    let idx = self.metas.len();
499    let xml_id = attr_value(&attrs, "xml:id");
500    if let Some(id) = xml_id.clone() {
501      self.top().run_toc.push(id);
502    }
503    self.top().run_active = true;
504    self.top().seen_ltx_insert(true, &localname);
505    self.metas.push(PageMeta {
506      file: self.spill_dir.join(format!("page-{idx:07}.xml")),
507      children: Vec::new(),
508      localname: localname.clone(),
509      xml_id,
510      labels: attr_value(&attrs, "labels"),
511      inlist: attr_value(&attrs, "inlist"),
512      class_appended: false,
513      name: String::new(),
514    });
515    self.metas[parent_meta].children.push(idx);
516    let ns_decls = decl_attrs(&attrs);
517    self.levels.push(Level {
518      kind: LevelKind::Page(idx),
519      localname,
520      ltx: true,
521      content: String::new(),
522      seen_ltx: FxHashSet::default(),
523      run_toc: Vec::new(),
524      run_active: false,
525      has_lists_toc: false,
526      has_direct_date: false,
527      attrs,
528      lang,
529      bg,
530      ns_decls,
531    });
532  }
533
534  /// Close the current level: flush its trailing run and write its spill.
535  fn close_top(&mut self) -> Result<(), String> {
536    self.flush_run();
537    let level = self.levels.pop().expect("close without an open level");
538    match level.kind {
539      LevelKind::Root => self.write_root_spill(level),
540      LevelKind::Page(idx) => self.write_page_spill(level, idx),
541    }
542  }
543
544  /// A non-page, non-wrapper subtree: append its serialization to the
545  /// current level's content, with the shared bookkeeping.
546  fn append_bulk(&mut self, outer: &str, localname: &str, ltx: bool) {
547    self.flush_run();
548    if ltx && localname == "date" {
549      if matches!(self.levels.last().map(|l| &l.kind), Some(LevelKind::Root)) {
550        self.warn_if_late("an ltx:date");
551        self.dates_xml.push(outer.to_string());
552      }
553      self.top().has_direct_date = true;
554    }
555    let is_resource = ltx && localname == "resource";
556    if is_resource {
557      self.warn_if_late("an ltx:resource");
558      self.resources_xml.push(outer.to_string());
559    }
560    self.bulk_probes(outer, is_resource);
561    self.top().content.push_str(outer);
562  }
563
564  /// Content probes shared by every bulk append: the TOC-suppression flag,
565  /// picture collection for SVG extraction, embedded `<?latexml?>` PI
566  /// bodies, and the nested-resource warning.
567  fn bulk_probes(&mut self, outer: &str, expected_resource: bool) {
568    if probe_lists_toc(outer) {
569      self.top().has_lists_toc = true;
570    }
571    if outer.contains("<picture") || outer.contains(":picture") {
572      collect_pictures(outer, &mut self.picture_xml);
573    }
574    // `expected_resource`: the subtree IS a direct-child ltx:resource that
575    // append_bulk already collected — its own serialization must not trip the
576    // nested-resource flag.
577    if !expected_resource
578      && (outer.contains("<resource") || outer.contains(":resource"))
579      && !self.warned_late
580    {
581      self.warned_late = true;
582      Warn!(
583        "split",
584        "stream",
585        "an ltx:resource nested inside content is not propagated to page templates by the streaming split"
586      );
587    }
588    if outer.contains("<?latexml") {
589      for body in extract_pi_bodies(outer) {
590        self.latexml_pis.push(body);
591      }
592    }
593  }
594
595  /// Flush the current adjacent-page run: emit its TOC (unless the
596  /// suppression probe fired) at the current content position.
597  fn flush_run(&mut self) {
598    let level = self.top();
599    if !level.run_active {
600      return;
601    }
602    level.run_active = false;
603    if level.run_toc.is_empty() {
604      return;
605    }
606    let entries = std::mem::take(&mut level.run_toc);
607    if level.has_lists_toc {
608      return;
609    }
610    let parent_type = level.localname.clone();
611    let toc = self.toc_xml(&parent_type, &entries);
612    self.top().content.push_str(&toc);
613  }
614
615  fn toc_xml(&self, parent_type: &str, ids: &[String]) -> String {
616    let te = self.ltx_qname("tocentry");
617    let re = self.ltx_qname("ref");
618    let entries: String = ids
619      .iter()
620      .map(|id| {
621        format!(
622          "<{te}><{re} idref=\"{id}\" show=\"toctitle\"/></{te}>",
623          id = attr_escape(id)
624        )
625      })
626      .collect();
627    format!(
628      "<{toc}><{list} class=\"ltx_toclist_{ptype}\">{entries}</{list}></{toc}>",
629      toc = self.ltx_qname("TOC"),
630      list = self.ltx_qname("toclist"),
631      ptype = attr_escape(parent_type),
632    )
633  }
634
635  // ====================================================================
636  // Spill assembly
637
638  /// The serialized qname for an `ltx:` element in this document's
639  /// vocabulary (empty prefix when ltx is the default namespace — the
640  /// standard core-XML shape).
641  fn ltx_qname(&self, localname: &str) -> String {
642    for level in &self.levels {
643      for (prefix, uri) in &level.ns_decls {
644        if uri == LTX_NSURI {
645          return if prefix.is_empty() {
646            localname.to_string()
647          } else {
648            format!("{prefix}:{localname}")
649          };
650        }
651      }
652    }
653    localname.to_string()
654  }
655
656  /// The qname for a level's own element, resolved against its declarations
657  /// plus the enclosing stack.
658  fn qname_for(&self, level: &Level) -> String {
659    if level.ltx {
660      for (p, u) in decl_attrs(&level.attrs) {
661        if u == LTX_NSURI {
662          return if p.is_empty() {
663            level.localname.clone()
664          } else {
665            format!("{}:{}", p, level.localname)
666          };
667        }
668      }
669      self.ltx_qname(&level.localname)
670    } else {
671      // Non-ltx roots keep their serialized shape via their own attrs; pages
672      // are always ltx (the split union is ltx-only).
673      level.localname.clone()
674    }
675  }
676
677  /// The namespace declarations in scope from the OPEN levels (root first),
678  /// first declaration per prefix winning — what a standalone page file must
679  /// re-declare for its content to re-parse identically.
680  fn enclosing_decls(&self) -> Vec<(String, String)> {
681    let mut decls: Vec<(String, String)> = Vec::new();
682    for lvl in &self.levels {
683      for (p, u) in &lvl.ns_decls {
684        if !decls.iter().any(|(dp, _)| dp == p) {
685          decls.push((p.clone(), u.clone()));
686        }
687      }
688    }
689    decls
690  }
691
692  fn write_page_spill(&mut self, level: Level, idx: usize) -> Result<(), String> {
693    let mut attrs = level.attrs.clone();
694    // Inherited attributes: nearest ancestor-or-self, appended when absent
695    // (the DOM path's `set_attribute` appends), xml:lang before
696    // backgroundcolor.
697    if attr_value(&attrs, "xml:lang").is_none()
698      && let Some(lang) = &level.lang
699    {
700      attrs.push(("xml:lang".to_string(), lang.clone()));
701    }
702    if attr_value(&attrs, "backgroundcolor").is_none()
703      && let Some(bg) = &level.bg
704    {
705      attrs.push(("backgroundcolor".to_string(), bg.clone()));
706    }
707    // Root class merge (Perl Post.pm L779-782 via `new_document`).
708    let mut class_appended = false;
709    if let Some(pclass) = &self.root_class {
710      match attrs.iter_mut().find(|(k, _)| k == "class") {
711        Some((_, existing)) if !existing.is_empty() => {
712          existing.push(' ');
713          existing.push_str(pclass);
714        },
715        Some((_, existing)) => *existing = pclass.clone(),
716        None => {
717          attrs.push(("class".to_string(), pclass.clone()));
718          class_appended = true;
719        },
720      }
721    }
722    self.metas[idx].class_appended = class_appended;
723    // The standalone page file must re-declare every namespace its content
724    // may reference: the enclosing declarations (root + open ancestors) the
725    // element does not redeclare itself.
726    let mut decls = self.enclosing_decls();
727    let own_decls = decl_attrs(&level.attrs);
728    decls.retain(|(p, _)| !own_decls.iter().any(|(op, _)| op == p));
729    let qname = self.qname_for(&level);
730    let mut content = level.content;
731    // `new_document` template: resources, then dates (when the page has no
732    // direct `ltx:date` child), then the saved navigation. Class was merged
733    // above; PIs go in the prolog.
734    for r in &self.resources_xml {
735      content.push_str(r);
736    }
737    if !level.has_direct_date {
738      for d in &self.dates_xml {
739        content.push_str(d);
740      }
741    }
742    for nav in &self.navs_xml {
743      content.push_str(nav);
744    }
745    let mut tag = String::with_capacity(qname.len() + 64);
746    tag.push('<');
747    tag.push_str(&qname);
748    for (p, u) in &decls {
749      if p.is_empty() {
750        tag.push_str(&format!(" xmlns=\"{}\"", attr_escape(u)));
751      } else {
752        tag.push_str(&format!(" xmlns:{}=\"{}\"", p, attr_escape(u)));
753      }
754    }
755    for (k, v) in &attrs {
756      tag.push_str(&format!(" {}=\"{}\"", k, attr_escape(v)));
757    }
758    let out = assemble_spill(&self.spill_prolog(), &tag, &qname, &content, "");
759    write_spill(&self.metas[idx].file, &out)?;
760    self.first_page_spilled = true;
761    Ok(())
762  }
763
764  fn write_root_spill(&mut self, level: Level) -> Result<(), String> {
765    let mut attrs = level.attrs.clone();
766    // `Split::process`: the root gets a placeholder id (the Writer removes
767    // it from the final output).
768    if attr_value(&attrs, "xml:id").is_none() {
769      attrs.push(("xml:id".to_string(), "TEMPORARY_DOCUMENT_ID".to_string()));
770      self.metas[0].xml_id = Some("TEMPORARY_DOCUMENT_ID".to_string());
771    }
772    let qname = self.qname_for(&level);
773    let mut content = level.content;
774    for nav in &self.navs_xml {
775      content.push_str(nav);
776    }
777    let mut tag = String::with_capacity(128);
778    tag.push('<');
779    tag.push_str(&qname);
780    for (k, v) in &attrs {
781      tag.push_str(&format!(" {}=\"{}\"", k, attr_escape(v)));
782    }
783    let prolog = format!(
784      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
785      self.root_prolog
786    );
787    let out = assemble_spill(&prolog, &tag, &qname, &content, &self.root_tail);
788    write_spill(&self.metas[0].file, &out)
789  }
790
791  /// The prolog every non-root page file starts with: XML declaration plus
792  /// the `<?latexml …?>` template PIs.
793  fn spill_prolog(&self) -> String {
794    let mut prolog = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
795    for body in &self.latexml_pis {
796      prolog.push_str(&format!("<?latexml {body}?>\n"));
797    }
798    prolog
799  }
800
801  // ====================================================================
802  // DOM descent (wrapper subtrees)
803
804  /// Conservative probe: does this serialized subtree contain anything that
805  /// requires real DOM surgery (a potential page match, or an
806  /// `ltx:navigation` to excise)? Over-triggering costs one expand+copy, not
807  /// correctness.
808  fn needs_dom_descent(&self, outer: &str) -> bool {
809    if contains_element_probe(outer, "navigation") {
810      return true;
811    }
812    self
813      .arms
814      .iter()
815      .any(|arm| contains_element_probe(outer, &arm.element))
816  }
817
818  /// Run the split surgery on an owned mini-document (a wrapper subtree):
819  /// excise `ltx:navigation`, pre-collect page matches on the intact tree
820  /// (the DOM path evaluates its predicates before any surgery), extract
821  /// them (writing their spills, TOCs inserted in place), then serialize the
822  /// remaining shell into the current level's content.
823  fn descend_wrapper(&mut self, minidoc: &mut Document) -> Result<(), String> {
824    let root = minidoc
825      .get_root_element()
826      .ok_or_else(|| "wrapper mini-document has no root".to_string())?;
827    // Navigation excision first, exactly as `Split::process` removes
828    // `descendant::ltx:navigation` before page surgery.
829    let mut navs: Vec<Node> = Vec::new();
830    collect_ltx_descendants(&root, "navigation", &mut navs);
831    for nav in &mut navs {
832      let s = minidoc.node_to_string(nav);
833      nav.unlink_node();
834      self.navs_xml.push(s);
835    }
836    // Pre-collect page matches on the intact (nav-free) tree.
837    let mut page_nodes: Vec<Node> = Vec::new();
838    collect_split_pages(&root, &self.arms, &mut page_nodes);
839    let parent_level = self.levels.last().expect("wrapper under an open level");
840    let (lang, bg) = (parent_level.lang.clone(), parent_level.bg.clone());
841    self.descend_element(minidoc, &root, &page_nodes, lang, bg)?;
842    // The remaining shell (with TOCs in place of extracted runs) is ordinary
843    // content of the current level. Do NOT re-run the picture/PI probes —
844    // the per-child descent already collected them; only the enclosing
845    // TOC-suppression flag needs the shell.
846    let shell = minidoc.node_to_string(&root);
847    self.flush_run();
848    if probe_lists_toc(&shell) {
849      self.top().has_lists_toc = true;
850    }
851    self.top().content.push_str(&shell);
852    Ok(())
853  }
854
855  /// The DOM flavor of the reader pump: iterate `node`'s children, extract
856  /// page children (and their descendants), insert run TOCs in place.
857  /// `lang`/`bg` are the effective inherited attributes *above* `node`.
858  fn descend_element(
859    &mut self,
860    doc: &Document,
861    node: &Node,
862    page_nodes: &[Node],
863    lang: Option<String>,
864    bg: Option<String>,
865  ) -> Result<(), String> {
866    let lang = xml_ns_attr(node, "lang").or(lang);
867    let bg = node.get_attribute("backgroundcolor").or(bg);
868    let mut run_toc: Vec<String> = Vec::new();
869    let mut run_active = false;
870    // Incremental suppression probe: only content BEFORE a run counts, as in
871    // the DOM path (following siblings are unlinked before its probe runs).
872    let mut has_lists_toc = false;
873    let node_name = node.get_name();
874    let mut child_opt = node.get_first_child();
875    while let Some(child) = child_opt {
876      let next = child.get_next_sibling();
877      let is_page_child =
878        child.get_type() == Some(NodeType::ElementNode) && page_nodes.contains(&child);
879      if is_page_child {
880        run_active = true;
881        self.dom_extract_page(
882          doc,
883          &child,
884          page_nodes,
885          lang.clone(),
886          bg.clone(),
887          &mut run_toc,
888        )?;
889        let mut extracted = child;
890        extracted.unlink_node();
891      } else {
892        // Any other sibling breaks the run: flush its TOC *before* this
893        // child.
894        if run_active {
895          run_active = false;
896          if !run_toc.is_empty() && !has_lists_toc {
897            let entries = std::mem::take(&mut run_toc);
898            self.dom_insert_toc(doc, node, Some(&child), &node_name, &entries)?;
899          }
900          run_toc.clear(); // ids of a suppressed run are discarded, as in the DOM path
901        }
902        if child.get_type() == Some(NodeType::ElementNode) {
903          let serialized = doc.node_to_string(&child);
904          if self.needs_dom_descent(&serialized) {
905            self.descend_element(doc, &child, page_nodes, lang.clone(), bg.clone())?;
906          } else {
907            self.bulk_probes_dom(&serialized);
908          }
909          if has_lists_toc_node(&child) || has_lists_toc_descendant(&child) {
910            has_lists_toc = true;
911          }
912        }
913      }
914      child_opt = next;
915    }
916    if run_active && !run_toc.is_empty() && !has_lists_toc {
917      let entries = std::mem::take(&mut run_toc);
918      self.dom_insert_toc(doc, node, None, &node_name, &entries)?;
919    }
920    Ok(())
921  }
922
923  /// The picture/PI probes for DOM-descent bulk content (`has_lists_toc` is
924  /// tracked by the caller's incremental probe, and the shell append handles
925  /// the enclosing level's flag).
926  fn bulk_probes_dom(&mut self, serialized: &str) {
927    if serialized.contains("<picture") || serialized.contains(":picture") {
928      collect_pictures(serialized, &mut self.picture_xml);
929    }
930    if serialized.contains("<?latexml") {
931      for body in extract_pi_bodies(serialized) {
932        self.latexml_pis.push(body);
933      }
934    }
935  }
936
937  /// Extract one page found during DOM descent: register its metadata, add
938  /// its tocentry to the enclosing run, recursively extract ITS page
939  /// descendants, then serialize + amend + write its spill.
940  fn dom_extract_page(
941    &mut self,
942    doc: &Document,
943    page: &Node,
944    page_nodes: &[Node],
945    lang: Option<String>,
946    bg: Option<String>,
947    run_toc: &mut Vec<String>,
948  ) -> Result<(), String> {
949    let parent_meta = match self.levels.last().expect("open level").kind {
950      LevelKind::Root => 0,
951      LevelKind::Page(i) => i,
952    };
953    let idx = self.metas.len();
954    let xml_id = crate::document::get_xml_id(page);
955    if let Some(id) = &xml_id {
956      run_toc.push(id.clone());
957    }
958    self.metas.push(PageMeta {
959      file: self.spill_dir.join(format!("page-{idx:07}.xml")),
960      children: Vec::new(),
961      localname: page.get_name(),
962      xml_id,
963      labels: page.get_attribute("labels"),
964      inlist: page.get_attribute("inlist"),
965      class_appended: false,
966      name: String::new(),
967    });
968    self.metas[parent_meta].children.push(idx);
969    // Bookkeeping level so deeper extractions attach to this page in the
970    // metadata tree (content stays in the live mini-DOM).
971    self.levels.push(Level {
972      kind:            LevelKind::Page(idx),
973      localname:       page.get_name(),
974      ltx:             true,
975      content:         String::new(),
976      seen_ltx:        FxHashSet::default(),
977      run_toc:         Vec::new(),
978      run_active:      false,
979      has_lists_toc:   false,
980      has_direct_date: false,
981      attrs:           Vec::new(),
982      lang:            lang.clone(),
983      bg:              bg.clone(),
984      ns_decls:        Vec::new(),
985    });
986    let descend_result = self.descend_element(doc, page, page_nodes, lang.clone(), bg.clone());
987    self.levels.pop();
988    descend_result?;
989    // Serialize the (now child-page-free) page subtree; the serialization
990    // preserves the original attribute order natively. Then amend the tag
991    // with the inherited/class attributes and namespace re-declarations, and
992    // splice the template trailer before the close tag.
993    let mut serialized = doc.node_to_string(page);
994    let has_direct_date = page
995      .get_child_elements()
996      .iter()
997      .any(|c| c.get_name() == "date" && is_ltx(c));
998    let mut trailer = String::new();
999    for r in &self.resources_xml {
1000      trailer.push_str(r);
1001    }
1002    if !has_direct_date {
1003      for d in &self.dates_xml {
1004        trailer.push_str(d);
1005      }
1006    }
1007    for nav in &self.navs_xml {
1008      trailer.push_str(nav);
1009    }
1010    let effective_lang = xml_ns_attr(page, "lang").or(lang);
1011    let effective_bg = page.get_attribute("backgroundcolor").or(bg);
1012    let mut extra_attrs: Vec<(String, String)> = Vec::new();
1013    if xml_ns_attr(page, "lang").is_none()
1014      && let Some(l) = &effective_lang
1015    {
1016      extra_attrs.push(("xml:lang".to_string(), l.clone()));
1017    }
1018    if page.get_attribute("backgroundcolor").is_none()
1019      && let Some(b) = &effective_bg
1020    {
1021      extra_attrs.push(("backgroundcolor".to_string(), b.clone()));
1022    }
1023    let mut class_appended = false;
1024    let mut class_merge: Option<String> = None;
1025    if let Some(pclass) = &self.root_class {
1026      if page.get_attribute("class").is_some() {
1027        class_merge = Some(pclass.clone());
1028      } else {
1029        extra_attrs.push(("class".to_string(), pclass.clone()));
1030        class_appended = true;
1031      }
1032    }
1033    self.metas[idx].class_appended = class_appended;
1034    let decls = self.enclosing_decls();
1035    amend_serialized_page(
1036      &mut serialized,
1037      &decls,
1038      &extra_attrs,
1039      class_merge.as_deref(),
1040      &trailer,
1041    )?;
1042    let mut out = self.spill_prolog();
1043    out.push_str(&serialized);
1044    out.push('\n');
1045    write_spill(&self.metas[idx].file, &out)?;
1046    self.first_page_spilled = true;
1047    Ok(())
1048  }
1049
1050  /// Insert a generated TOC element into the live mini-DOM before `anchor`
1051  /// (or append, when the run ended at the element's close). Built with
1052  /// direct node construction (the `PostDocument::add_nodes` pattern: prefer
1053  /// the in-scope default ltx declaration so serialization matches the
1054  /// document's own vocabulary).
1055  fn dom_insert_toc(
1056    &mut self,
1057    doc: &Document,
1058    parent: &Node,
1059    anchor: Option<&Node>,
1060    parent_type: &str,
1061    ids: &[String],
1062  ) -> Result<(), String> {
1063    let mut parent = parent.clone();
1064    let ns = parent
1065      .get_namespaces(doc)
1066      .into_iter()
1067      .find(|ns| ns.get_href() == LTX_NSURI && ns.get_prefix().is_empty())
1068      .or_else(|| {
1069        parent
1070          .get_namespaces(doc)
1071          .into_iter()
1072          .find(|ns| ns.get_href() == LTX_NSURI)
1073      });
1074    let mut toc = parent
1075      .new_child(ns.clone(), "TOC")
1076      .map_err(|e| format!("cannot create TOC element: {e}"))?;
1077    let ns = ns.or_else(|| Namespace::new("", LTX_NSURI, &mut toc).ok());
1078    let mut toclist = toc
1079      .new_child(ns.clone(), "toclist")
1080      .map_err(|e| format!("cannot create toclist: {e}"))?;
1081    toclist
1082      .set_attribute("class", &format!("ltx_toclist_{parent_type}"))
1083      .map_err(|e| format!("cannot set toclist class: {e:?}"))?;
1084    for id in ids {
1085      let mut entry = toclist
1086        .new_child(ns.clone(), "tocentry")
1087        .map_err(|e| format!("cannot create tocentry: {e}"))?;
1088      let mut r = entry
1089        .new_child(ns.clone(), "ref")
1090        .map_err(|e| format!("cannot create ref: {e}"))?;
1091      r.set_attribute("idref", id)
1092        .map_err(|e| format!("cannot set idref: {e:?}"))?;
1093      r.set_attribute("show", "toctitle")
1094        .map_err(|e| format!("cannot set show: {e:?}"))?;
1095    }
1096    if let Some(a) = anchor {
1097      // `new_child` appended the TOC; move it to the run's position.
1098      a.clone()
1099        .add_prev_sibling(&mut toc)
1100        .map_err(|e| format!("cannot position TOC: {e:?}"))?;
1101    }
1102    Ok(())
1103  }
1104
1105  // ====================================================================
1106  // Deferred passes
1107
1108  /// Port of `Split::prename_pages` + `get_page_name` over the metadata tree
1109  /// (names all children of a node, then recurses into each — the order the
1110  /// `FOO{n}` counter depends on).
1111  fn prename(&mut self) {
1112    let ext = Path::new(&self.root_destination)
1113      .extension()
1114      .map(|e| e.to_string_lossy().to_string())
1115      .unwrap_or_else(|| "xml".to_string());
1116    // `haschildren`: keyed by the localname of any element with page
1117    // children (the root's included) — drives `*Relative` dir naming.
1118    let mut haschildren: HashMap<String, bool> = HashMap::default();
1119    for m in &self.metas {
1120      if !m.children.is_empty() {
1121        haschildren.insert(m.localname.clone(), true);
1122      }
1123    }
1124    self.prename_rec(0, &ext, &haschildren);
1125  }
1126
1127  fn prename_rec(&mut self, node: usize, ext: &str, haschildren: &HashMap<String, bool>) {
1128    let children = self.metas[node].children.clone();
1129    for &child in &children {
1130      let recursive = haschildren
1131        .get(&self.metas[child].localname)
1132        .copied()
1133        .unwrap_or(false);
1134      let name = self.get_page_name(child, node, ext, recursive);
1135      self.metas[child].name = name;
1136    }
1137    for &child in &children {
1138      self.prename_rec(child, ext, haschildren);
1139    }
1140  }
1141
1142  /// Port of `Split::get_page_name` over metadata.
1143  fn get_page_name(&mut self, page: usize, parent: usize, ext: &str, recursive: bool) -> String {
1144    let use_labels = matches!(self.naming, SplitNaming::Label | SplitNaming::LabelRelative);
1145    let attr_name = if use_labels { "labels" } else { "xml:id" };
1146    let raw = if use_labels {
1147      self.metas[page].labels.clone()
1148    } else {
1149      self.metas[page].xml_id.clone()
1150    };
1151    let mut name = raw.unwrap_or_default();
1152    if let Some(first) = name.split_whitespace().next() {
1153      name = first.to_string();
1154    }
1155    if let Some(stripped) = name.strip_prefix("LABEL:") {
1156      name = stripped.to_string();
1157    }
1158    if name.is_empty() {
1159      if use_labels && let Some(id) = self.metas[page].xml_id.clone() {
1160        Info!(
1161          "split",
1162          "pathname",
1163          "Using '{}' to create page pathname, instead of missing '{}'",
1164          id,
1165          attr_name
1166        );
1167        name = id;
1168      } else {
1169        self.unnamed_counter += 1;
1170        name = format!("FOO{}", self.unnamed_counter);
1171        Info!(
1172          "split",
1173          "pathname",
1174          "Using '{}' to create page pathname, instead of missing '{}'",
1175          name,
1176          attr_name
1177        );
1178      }
1179    }
1180    let as_dir = match self.naming {
1181      SplitNaming::IdRelative | SplitNaming::LabelRelative => {
1182        let parent_attr = if use_labels {
1183          self.metas[parent].labels.clone()
1184        } else {
1185          self.metas[parent].xml_id.clone()
1186        };
1187        if let Some(pname) = parent_attr {
1188          let pname = pname.split_whitespace().next().unwrap_or("");
1189          let pname = pname.strip_prefix("LABEL:").unwrap_or(pname);
1190          if let Some(rest) = name.strip_prefix(pname) {
1191            let rest = rest.trim_start_matches(['.', '_', ':']);
1192            if !rest.is_empty() {
1193              name = rest.to_string();
1194            }
1195          }
1196        }
1197        recursive
1198      },
1199      _ => false,
1200    };
1201    name = name.replace(':', "_");
1202    let parent_path = &self.metas[parent].name;
1203    let parent_dir = Path::new(parent_path)
1204      .parent()
1205      .and_then(|p| p.to_str())
1206      .unwrap_or(".");
1207    let parent_dir = if parent_dir.is_empty() {
1208      "."
1209    } else {
1210      parent_dir
1211    };
1212    if as_dir {
1213      format!("{}/{}/index.{}", parent_dir, name, ext)
1214    } else {
1215      format!("{}/{}.{}", parent_dir, name, ext)
1216    }
1217  }
1218
1219  /// The `inlist="toc"` lookahead pass: for each tree level where any page's
1220  /// `inlist` contains `"toc"` (substring semantics, mirroring the DOM
1221  /// path), patch `inlist="toc"` into the spilled root tag of every sibling
1222  /// page that has no `inlist` of its own.
1223  fn patch_inlist_toc(&mut self) -> Result<(), String> {
1224    for node in 0..self.metas.len() {
1225      let children = &self.metas[node].children;
1226      if children.is_empty() {
1227        continue;
1228      }
1229      let intoc = children.iter().any(|&c| {
1230        self.metas[c]
1231          .inlist
1232          .as_deref()
1233          .is_some_and(|il| il.contains("toc"))
1234      });
1235      if !intoc {
1236        continue;
1237      }
1238      let to_patch: Vec<usize> = children
1239        .iter()
1240        .copied()
1241        .filter(|&c| self.metas[c].inlist.is_none())
1242        .collect();
1243      for c in to_patch {
1244        patch_spill_root_tag(
1245          &self.metas[c].file,
1246          "inlist",
1247          "toc",
1248          self.metas[c].class_appended,
1249        )?;
1250        self.metas[c].inlist = Some("toc".to_string());
1251      }
1252    }
1253    Ok(())
1254  }
1255}
1256
1257impl Level {
1258  fn seen_ltx_insert(&mut self, ltx: bool, localname: &str) {
1259    if ltx {
1260      self.seen_ltx.insert(localname.to_string());
1261    }
1262  }
1263}
1264
1265// ======================================================================
1266// Text-level helpers
1267
1268/// Assemble one spill file: prolog, then the element (self-closed when it
1269/// has no content), then the tail.
1270fn assemble_spill(prolog: &str, tag: &str, qname: &str, content: &str, tail: &str) -> String {
1271  let mut out = String::with_capacity(prolog.len() + tag.len() + content.len() + tail.len() + 16);
1272  out.push_str(prolog);
1273  out.push_str(tag);
1274  if content.is_empty() {
1275    out.push_str("/>\n");
1276  } else {
1277    out.push('>');
1278    out.push_str(content);
1279    out.push_str("</");
1280    out.push_str(qname);
1281    out.push_str(">\n");
1282  }
1283  out.push_str(tail);
1284  out
1285}
1286
1287/// libxml-compatible attribute-value escaping (`xmlAttrSerializeTxtContent`).
1288fn attr_escape(value: &str) -> String {
1289  let mut out = String::with_capacity(value.len());
1290  for ch in value.chars() {
1291    match ch {
1292      '&' => out.push_str("&amp;"),
1293      '<' => out.push_str("&lt;"),
1294      '>' => out.push_str("&gt;"),
1295      '"' => out.push_str("&quot;"),
1296      '\n' => out.push_str("&#10;"),
1297      '\r' => out.push_str("&#13;"),
1298      '\t' => out.push_str("&#9;"),
1299      _ => out.push(ch),
1300    }
1301  }
1302  out
1303}
1304
1305/// libxml-compatible text-node escaping.
1306fn text_escape(value: &str) -> String {
1307  let mut out = String::with_capacity(value.len());
1308  for ch in value.chars() {
1309    match ch {
1310      '&' => out.push_str("&amp;"),
1311      '<' => out.push_str("&lt;"),
1312      '>' => out.push_str("&gt;"),
1313      '\r' => out.push_str("&#13;"),
1314      _ => out.push(ch),
1315    }
1316  }
1317  out
1318}
1319
1320fn attr_value(attrs: &[(String, String)], name: &str) -> Option<String> {
1321  attrs
1322    .iter()
1323    .find(|(k, _)| k == name)
1324    .map(|(_, v)| v.clone())
1325}
1326
1327/// The namespace declarations among an element's attributes, as
1328/// `(prefix, uri)` pairs (`""` = default).
1329fn decl_attrs(attrs: &[(String, String)]) -> Vec<(String, String)> {
1330  let mut out = Vec::new();
1331  for (k, v) in attrs {
1332    if k == "xmlns" {
1333      out.push((String::new(), v.clone()));
1334    } else if let Some(p) = k.strip_prefix("xmlns:") {
1335      out.push((p.to_string(), v.clone()));
1336    }
1337  }
1338  out
1339}
1340
1341/// Read an `xml:`-namespaced attribute from a DOM node. A plain
1342/// `get_attribute("xml:lang")` can miss the namespaced form (the same trap
1343/// `get_xml_id` guards against), so try the namespace-aware read first.
1344fn xml_ns_attr(node: &Node, localname: &str) -> Option<String> {
1345  const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
1346  node
1347    .get_attribute_ns(localname, XML_NS)
1348    .or_else(|| node.get_attribute(&format!("xml:{localname}")))
1349}
1350
1351/// Conservative substring probe for "this serialized fragment may contain an
1352/// element with `localname`": matches `<localname` or `<pfx:localname`
1353/// followed by a name-boundary character, so `<indexmark` does NOT register
1354/// as `<index` (a real document's `\\index` markers would otherwise trigger a
1355/// mini-DOM descent per paragraph). Text content cannot introduce `<`
1356/// (escaped), so `<`-anchored matches are always element starts;
1357/// `:`-anchored matches can over-trigger on text (harmless — descent is
1358/// correct, just slower).
1359fn contains_element_probe(outer: &str, localname: &str) -> bool {
1360  let boundary = |rest: &str| {
1361    rest
1362      .as_bytes()
1363      .first()
1364      .is_none_or(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/'))
1365  };
1366  let bare = format!("<{localname}");
1367  for (at, _) in outer.match_indices(&bare) {
1368    if boundary(&outer[at + bare.len()..]) {
1369      return true;
1370    }
1371  }
1372  let prefixed = format!(":{localname}");
1373  for (at, _) in outer.match_indices(&prefixed) {
1374    if boundary(&outer[at + prefixed.len()..]) {
1375      return true;
1376    }
1377  }
1378  false
1379}
1380
1381/// Does this serialized fragment contain an `ltx:TOC` with `lists` exactly
1382/// `"toc"`? (The DOM probe is `@lists='toc'`, exact equality; generated TOCs
1383/// never match — they carry no `lists`.)
1384fn probe_lists_toc(outer: &str) -> bool {
1385  for (start, _) in outer
1386    .match_indices("<TOC")
1387    .chain(outer.match_indices(":TOC"))
1388  {
1389    let rest = &outer[start..];
1390    if let Some(end) = rest.find('>')
1391      && rest[..end].contains(" lists=\"toc\"")
1392    {
1393      return true;
1394    }
1395  }
1396  false
1397}
1398
1399/// Extract `ltx:picture` spans from a serialized fragment into the
1400/// SVG-extraction buffer. (The DOM path serializes each `//ltx:picture` node
1401/// and hands the concatenation to a regex-based fragment table; span
1402/// extraction at the text level is equivalent input for that table.)
1403fn collect_pictures(outer: &str, into: &mut String) {
1404  let mut search_from = 0;
1405  while let Some(rel) = outer[search_from..].find("<picture") {
1406    let start = search_from + rel;
1407    match outer[start..].find("</picture>") {
1408      Some(rel_end) => {
1409        let end = start + rel_end + "</picture>".len();
1410        into.push_str(&outer[start..end]);
1411        search_from = end;
1412      },
1413      None => break,
1414    }
1415  }
1416  // Prefixed form (`<pfx:picture …>` in prefixed documents).
1417  let mut search_from = 0;
1418  while let Some(rel) = outer[search_from..].find(":picture") {
1419    let colon = search_from + rel;
1420    let after = colon + ":picture".len();
1421    let is_open = outer[..colon].rfind('<').is_some_and(|lt| {
1422      lt + 1 < colon
1423        && !outer[lt..].starts_with("</")
1424        && outer[lt + 1..colon]
1425          .chars()
1426          .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1427    });
1428    if is_open {
1429      let lt = outer[..colon].rfind('<').expect("checked above");
1430      if let Some(rel_end) = outer[after..].find(":picture>") {
1431        let end = after + rel_end + ":picture>".len();
1432        into.push_str(&outer[lt..end]);
1433        search_from = end;
1434        continue;
1435      }
1436    }
1437    search_from = after;
1438  }
1439}
1440
1441/// Extract the bodies of `<?latexml …?>` PIs embedded in a serialized
1442/// fragment.
1443fn extract_pi_bodies(outer: &str) -> Vec<String> {
1444  let mut out = Vec::new();
1445  let mut from = 0;
1446  while let Some(rel) = outer[from..].find("<?latexml") {
1447    let start = from + rel + "<?latexml".len();
1448    if let Some(rel_end) = outer[start..].find("?>") {
1449      out.push(outer[start..start + rel_end].trim().to_string());
1450      from = start + rel_end + 2;
1451    } else {
1452      break;
1453    }
1454  }
1455  out
1456}
1457
1458/// Collect `ltx:` descendants named `localname` (pre-order, limit-safe walk).
1459fn collect_ltx_descendants(node: &Node, localname: &str, out: &mut Vec<Node>) {
1460  let mut child = node.get_first_child();
1461  while let Some(c) = child {
1462    if c.get_type() == Some(NodeType::ElementNode) {
1463      if c.get_name() == localname && is_ltx(&c) {
1464        out.push(c.clone());
1465      }
1466      collect_ltx_descendants(&c, localname, out);
1467    }
1468    child = c.get_next_sibling();
1469  }
1470}
1471
1472/// The `descendant::ltx:TOC[@lists='toc']` probe, DOM flavor.
1473fn has_lists_toc_descendant(node: &Node) -> bool {
1474  let mut child = node.get_first_child();
1475  while let Some(c) = child {
1476    if c.get_type() == Some(NodeType::ElementNode)
1477      && (has_lists_toc_node(&c) || has_lists_toc_descendant(&c))
1478    {
1479      return true;
1480    }
1481    child = c.get_next_sibling();
1482  }
1483  false
1484}
1485
1486fn has_lists_toc_node(node: &Node) -> bool {
1487  node.get_type() == Some(NodeType::ElementNode)
1488    && node.get_name() == "TOC"
1489    && is_ltx(node)
1490    && node.get_attribute("lists").as_deref() == Some("toc")
1491}
1492
1493/// Find the end of the first tag in a well-formed serialized element: the
1494/// first `>` outside quoted attribute values (attribute values may legally
1495/// contain `>`).
1496fn first_tag_end(xml: &str) -> Option<usize> {
1497  let bytes = xml.as_bytes();
1498  let mut in_quote: Option<u8> = None;
1499  for (i, &b) in bytes.iter().enumerate() {
1500    match in_quote {
1501      Some(q) => {
1502        if b == q {
1503          in_quote = None;
1504        }
1505      },
1506      None => match b {
1507        b'"' | b'\'' => in_quote = Some(b),
1508        b'>' => return Some(i),
1509        _ => {},
1510      },
1511    }
1512  }
1513  None
1514}
1515
1516/// Amend a DOM-serialized page: inject namespace re-declarations and extra
1517/// attributes into the opening tag, optionally merge the root class into an
1518/// existing `class` attribute, and splice the template trailer before the
1519/// closing tag.
1520fn amend_serialized_page(
1521  serialized: &mut String,
1522  decls: &[(String, String)],
1523  extra_attrs: &[(String, String)],
1524  class_merge: Option<&str>,
1525  trailer: &str,
1526) -> Result<(), String> {
1527  let tag_end =
1528    first_tag_end(serialized).ok_or_else(|| "malformed page serialization".to_string())?;
1529  let self_closing = serialized[..tag_end].ends_with('/');
1530  let insert_at = if self_closing { tag_end - 1 } else { tag_end };
1531  let mut additions = String::new();
1532  for (p, u) in decls {
1533    let probe = if p.is_empty() {
1534      " xmlns=".to_string()
1535    } else {
1536      format!(" xmlns:{p}=")
1537    };
1538    if !serialized[..tag_end].contains(&probe) {
1539      if p.is_empty() {
1540        additions.push_str(&format!(" xmlns=\"{}\"", attr_escape(u)));
1541      } else {
1542        additions.push_str(&format!(" xmlns:{}=\"{}\"", p, attr_escape(u)));
1543      }
1544    }
1545  }
1546  for (k, v) in extra_attrs {
1547    additions.push_str(&format!(" {}=\"{}\"", k, attr_escape(v)));
1548  }
1549  serialized.insert_str(insert_at, &additions);
1550  if let Some(pclass) = class_merge {
1551    let tag_end = first_tag_end(serialized).ok_or("malformed tag")?;
1552    if let Some(cpos) = serialized[..tag_end].find(" class=\"") {
1553      let vstart = cpos + " class=\"".len();
1554      if let Some(vlen) = serialized[vstart..tag_end].find('"') {
1555        let existing = serialized[vstart..vstart + vlen].to_string();
1556        let merged = if existing.is_empty() {
1557          attr_escape(pclass)
1558        } else {
1559          format!("{} {}", existing, attr_escape(pclass))
1560        };
1561        serialized.replace_range(vstart..vstart + vlen, &merged);
1562      }
1563    }
1564  }
1565  if !trailer.is_empty() {
1566    let tag_end = first_tag_end(serialized).ok_or("malformed tag")?;
1567    if serialized[..=tag_end].ends_with("/>") {
1568      // `<x/>` → `<x>trailer</x>`: re-open the element.
1569      let qname_end = serialized
1570        .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
1571        .unwrap_or(tag_end);
1572      let qname = serialized[1..qname_end].to_string();
1573      serialized.truncate(tag_end - 1);
1574      serialized.push('>');
1575      serialized.push_str(trailer);
1576      serialized.push_str(&format!("</{qname}>"));
1577    } else if let Some(close_at) = serialized.rfind("</") {
1578      serialized.insert_str(close_at, trailer);
1579    }
1580  }
1581  Ok(())
1582}
1583
1584/// Patch ` name="value"` into a spill file's root element tag, inserting
1585/// before the trailing `class` attribute when the class was appended by the
1586/// merge (reproducing the DOM path's attribute order: inherit → inlist →
1587/// class).
1588fn patch_spill_root_tag(
1589  file: &Path,
1590  name: &str,
1591  value: &str,
1592  before_appended_class: bool,
1593) -> Result<(), String> {
1594  let content = std::fs::read_to_string(file).map_err(|e| {
1595    format!(
1596      "cannot read staged page {} for patching: {e}",
1597      file.display()
1598    )
1599  })?;
1600  // Locate the root element: the first `<` not opening a PI or comment.
1601  let mut pos = 0;
1602  let root_start = loop {
1603    let rel = content[pos..]
1604      .find('<')
1605      .ok_or_else(|| "no root tag in spill".to_string())?;
1606    let at = pos + rel;
1607    if content[at..].starts_with("<?") {
1608      pos = at + content[at..].find("?>").ok_or("unterminated PI")? + 2;
1609    } else if content[at..].starts_with("<!--") {
1610      pos = at + content[at..].find("-->").ok_or("unterminated comment")? + 3;
1611    } else {
1612      break at;
1613    }
1614  };
1615  let tag_end = root_start
1616    + first_tag_end(&content[root_start..]).ok_or_else(|| "malformed root tag".to_string())?;
1617  let tag_self_close_adjust = if content[..tag_end].ends_with('/') {
1618    tag_end - 1
1619  } else {
1620    tag_end
1621  };
1622  let insert_at = if before_appended_class {
1623    content[root_start..tag_end]
1624      .rfind(" class=\"")
1625      .map(|rel| root_start + rel)
1626      .unwrap_or(tag_self_close_adjust)
1627  } else {
1628    tag_self_close_adjust
1629  };
1630  let mut patched = String::with_capacity(content.len() + 16);
1631  patched.push_str(&content[..insert_at]);
1632  patched.push_str(&format!(" {}=\"{}\"", name, attr_escape(value)));
1633  patched.push_str(&content[insert_at..]);
1634  std::fs::write(file, patched).map_err(|e| format!("cannot rewrite spill: {e}"))
1635}
1636
1637fn write_spill(path: &Path, content: &str) -> Result<(), String> {
1638  let mut f = std::fs::File::create(path)
1639    .map_err(|e| format!("cannot create spill {}: {e}", path.display()))?;
1640  f.write_all(content.as_bytes())
1641    .map_err(|e| format!("cannot write spill {}: {e}", path.display()))
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646  use super::*;
1647
1648  #[test]
1649  fn escaping_matches_libxml() {
1650    assert_eq!(
1651      attr_escape("a<b>&\"c\"\nd\te\r"),
1652      "a&lt;b&gt;&amp;&quot;c&quot;&#10;d&#9;e&#13;"
1653    );
1654    assert_eq!(text_escape("a<b>&c\r"), "a&lt;b&gt;&amp;c&#13;");
1655  }
1656
1657  #[test]
1658  fn first_tag_end_skips_gt_inside_quotes() {
1659    assert_eq!(first_tag_end(r#"<x a="1>2">rest</x>"#), Some(10));
1660    assert_eq!(first_tag_end("<x/>"), Some(3));
1661    assert_eq!(first_tag_end("<never-closed"), None);
1662  }
1663
1664  #[test]
1665  fn probe_lists_toc_is_exact() {
1666    assert!(probe_lists_toc(r#"<p><TOC lists="toc"><t/></TOC></p>"#));
1667    assert!(probe_lists_toc(r#"<ltx:TOC lists="toc"/>"#));
1668    // Generated TOCs (no lists attribute) never match.
1669    assert!(!probe_lists_toc(r#"<TOC><toclist class="c"/></TOC>"#));
1670    // Exact equality: 'toc lof' does not match the @lists='toc' probe.
1671    assert!(!probe_lists_toc(r#"<TOC lists="toc lof"/>"#));
1672  }
1673
1674  #[test]
1675  fn amend_injects_attrs_and_trailer() {
1676    let mut s = r#"<section xml:id="S1"><p>t</p></section>"#.to_string();
1677    amend_serialized_page(
1678      &mut s,
1679      &[(String::new(), "urn:ns".to_string())],
1680      &[("xml:lang".to_string(), "en".to_string())],
1681      None,
1682      "<date>d</date>",
1683    )
1684    .unwrap();
1685    // Additions (declarations + attributes) append after the original
1686    // attributes; declaration position within a tag is semantically free.
1687    assert_eq!(
1688      s,
1689      r#"<section xml:id="S1" xmlns="urn:ns" xml:lang="en"><p>t</p><date>d</date></section>"#
1690    );
1691  }
1692
1693  #[test]
1694  fn amend_reopens_self_closed_page_for_trailer() {
1695    let mut s = "<chapter/>".to_string();
1696    amend_serialized_page(&mut s, &[], &[], None, "<x/>").unwrap();
1697    assert_eq!(s, "<chapter><x/></chapter>");
1698  }
1699
1700  #[test]
1701  fn amend_merges_class() {
1702    let mut s = r#"<section class="own"><p/></section>"#.to_string();
1703    amend_serialized_page(&mut s, &[], &[], Some("root"), "").unwrap();
1704    assert_eq!(s, r#"<section class="own root"><p/></section>"#);
1705  }
1706
1707  #[test]
1708  fn patch_inserts_before_appended_class() {
1709    let dir = std::env::temp_dir();
1710    let file = dir.join(format!("lxo-patch-test-{}.xml", std::process::id()));
1711    std::fs::write(
1712      &file,
1713      "<?xml version=\"1.0\"?>\n<?latexml p?>\n<section xml:id=\"S1\" class=\"root\"><p/></section>\n",
1714    )
1715    .unwrap();
1716    patch_spill_root_tag(&file, "inlist", "toc", true).unwrap();
1717    let out = std::fs::read_to_string(&file).unwrap();
1718    assert!(
1719      out.contains(r#"<section xml:id="S1" inlist="toc" class="root">"#),
1720      "patched tag order wrong: {out}"
1721    );
1722    // Without the appended-class flag, the attribute lands last.
1723    std::fs::write(
1724      &file,
1725      "<?xml version=\"1.0\"?>\n<section xml:id=\"S1\" class=\"own\"><p/></section>\n",
1726    )
1727    .unwrap();
1728    patch_spill_root_tag(&file, "inlist", "toc", false).unwrap();
1729    let out = std::fs::read_to_string(&file).unwrap();
1730    assert!(
1731      out.contains(r#"<section xml:id="S1" class="own" inlist="toc">"#),
1732      "patched tag order wrong: {out}"
1733    );
1734    std::fs::remove_file(&file).ok();
1735  }
1736
1737  #[test]
1738  fn collect_pictures_extracts_spans() {
1739    let mut buf = String::new();
1740    collect_pictures(
1741      r#"<p>x</p><picture xml:id="p1"><g/></picture><q/><picture xml:id="p2"/>...</picture>"#,
1742      &mut buf,
1743    );
1744    assert!(buf.starts_with(r#"<picture xml:id="p1"><g/></picture>"#));
1745  }
1746
1747  /// End-to-end mini split: a wrapper (backmatter) page must carry the ltx
1748  /// namespace declaration in its spill, or the XSLT will not recognize it.
1749  #[test]
1750  fn wrapper_page_spill_is_namespaced() {
1751    let dir = tempfile::tempdir().unwrap();
1752    let src = dir.path().join("mini.xml");
1753    std::fs::write(
1754      &src,
1755      r#"<?xml version="1.0" encoding="UTF-8"?>
1756<document xmlns="http://dlmf.nist.gov/LaTeXML" class="rc">
1757  <title>T</title>
1758  <chapter xml:id="C1"><title>One</title><para xml:id="C1.p1"><p>x</p></para></chapter>
1759  <backmatter>
1760    <section xml:id="BM.S1"><title>BS</title></section>
1761    <appendix xml:id="A1"><title>App</title></appendix>
1762  </backmatter>
1763</document>
1764"#,
1765    )
1766    .unwrap();
1767    let spill = dir.path().join("spill");
1768    std::fs::create_dir(&spill).unwrap();
1769    let union = "//ltx:section | //ltx:chapter | //ltx:appendix[preceding-sibling::ltx:section or parent::ltx:chapter]";
1770    let outcome = stream_split(
1771      &src.to_string_lossy(),
1772      union,
1773      SplitNaming::Id,
1774      Some("out/mini.html"),
1775      &spill,
1776    )
1777    .expect("split runs")
1778    .expect("split produces pages");
1779    let names: Vec<&str> = outcome
1780      .pages
1781      .iter()
1782      .map(|p| p.destination.as_str())
1783      .collect();
1784    assert_eq!(
1785      names,
1786      vec![
1787        "out/mini.html",
1788        "out/C1.html",
1789        "out/BM.S1.html",
1790        "out/A1.html"
1791      ],
1792      "pre-order destinations"
1793    );
1794    for page in &outcome.pages[1..] {
1795      let content = std::fs::read_to_string(&page.path).unwrap();
1796      assert!(
1797        content.contains("xmlns=\"http://dlmf.nist.gov/LaTeXML\""),
1798        "page {} must declare the ltx namespace:\n{content}",
1799        page.destination
1800      );
1801      // Reparse: root element must be in the ltx namespace.
1802      let doc = libxml::parser::Parser::default()
1803        .parse_string(&content)
1804        .expect("page reparses");
1805      let root = doc.get_root_element().unwrap();
1806      assert_eq!(
1807        root.get_namespace().map(|n| n.get_href()),
1808        Some(LTX_NSURI.to_string()),
1809        "page {} root not in ltx namespace:\n{content}",
1810        page.destination
1811      );
1812    }
1813  }
1814
1815  #[test]
1816  fn element_probe_respects_name_boundaries() {
1817    // `<indexmark>` must NOT register as an `index` element…
1818    assert!(!contains_element_probe(
1819      "<p><indexmark k=\"x\"/></p>",
1820      "index"
1821    ));
1822    // …while real starts do, in every tag shape and prefixed form.
1823    assert!(contains_element_probe("<p><index r=\"1\"/></p>", "index"));
1824    assert!(contains_element_probe("<index>", "index"));
1825    assert!(contains_element_probe("<index/>", "index"));
1826    assert!(contains_element_probe("<ltx:index>x</ltx:index>", "index"));
1827    assert!(!contains_element_probe("<subsubsection>", "subsection"));
1828    assert!(!contains_element_probe("plain text", "section"));
1829  }
1830
1831  #[test]
1832  fn pi_bodies_extracted() {
1833    assert_eq!(
1834      extract_pi_bodies(r#"<a><?latexml package="x"?><?other y?><?latexml class="c"?></a>"#),
1835      vec![r#"package="x""#.to_string(), r#"class="c""#.to_string()]
1836    );
1837  }
1838}