Skip to main content

latexml/
core_interface.rs

1use std::{path::Path, rc::Rc};
2
3// Top-level re-exports + the `Token!` macro (distinct from
4// `latexml_core::token::Token` type imported above).
5use latexml_core::{
6  CharToken, Core, Debug, Error, Explode, Fatal, T_CS, T_SPACE, Token, fatal, map, s,
7};
8use latexml_core::{
9  common::{
10    DigestionMode, arena,
11    error::{self, Result, emit_info, emit_warn, note_begin, note_end},
12    model,
13    store::Stored,
14  },
15  definition::expandable::Expandable,
16  digested::Digested,
17  document::Document,
18  gullet,
19  list::List,
20  pin,
21  rewrite::{Rewrite, RewriteOptions},
22  state::{self, Scope},
23  stomach,
24  token::{Catcode, Token},
25  tokens::Tokens,
26  util::{pathname, pathname::PathnameFindOptions},
27};
28use latexml_math_parser::MathParser;
29use once_cell::sync::Lazy;
30use regex::Regex;
31use rustc_hash::FxHashMap as HashMap;
32
33// Process-once cached env var (see WISDOM #56 — getenv hot-path race).
34static LATEXML_DUMP: Lazy<Option<String>> = Lazy::new(|| std::env::var("LATEXML_DUMP").ok());
35
36/// The latexml-oxide version exposed to bindings as the state value
37/// `LATEXML_VERSION` — the Rust analog of Perl's `$LaTeXML::VERSION`. This is
38/// **our own** crate version (`latexml_oxide`), not the emulated Perl LaTeXML's,
39/// rendered as a bare `X.Y.Z`: Cargo's `_MAJOR`/`_MINOR`/`_PATCH` components drop
40/// any `-rc`/pre-release suffix, so a version-gate parser (BookML's `.ltxml`
41/// check or the XSLT `b:version-leq`) sees three integer parts. `latexml_contrib`
42/// and `latexml_post` can't read this crate's version directly (reverse dep), so
43/// it is injected via state at session init and read back where needed.
44pub const LATEXML_VERSION: &str = concat!(
45  env!("CARGO_PKG_VERSION_MAJOR"),
46  ".",
47  env!("CARGO_PKG_VERSION_MINOR"),
48  ".",
49  env!("CARGO_PKG_VERSION_PATCH"),
50);
51use latexml_package::prelude::{
52  InputDefinitionOptions, InputOptions, input_content, input_definitions,
53};
54
55/// Perl `Core.pm` L272 `s/^\[([^\]]*)\]//` — the preload option bracket, which
56/// comes at the *front* of the spec (`[twocolumn,11pt]article.cls`).
57static LATEX_OPTION_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\[([^\]]*)\]").unwrap());
58
59// Regex for parsing DefMathRewrite calls from .latexml files
60// Matches: DefMathRewrite( ... );
61static DEF_MATH_REWRITE_RE: Lazy<Regex> =
62  Lazy::new(|| Regex::new(r"(?s)DefMathRewrite\(([^;]+)\);").unwrap());
63// Key-value patterns within DefMathRewrite
64static SCOPE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"scope\s*=>\s*'([^']+)'").unwrap());
65static MATCH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"match\s*=>\s*'([^']*)'").unwrap());
66static ROLE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"role\s*=>\s*'([^']+)'").unwrap());
67static NAME_ATTR_RE: Lazy<Regex> =
68  Lazy::new(|| Regex::new(r"(?:^|,)\s*name\s*=>\s*'([^']*)'").unwrap());
69static MEANING_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"meaning\s*=>\s*'([^']*)'").unwrap());
70
71#[derive(Default)]
72pub struct DigestionOptions {
73  pub mode:         Option<DigestionMode>,
74  pub noinitialize: Option<bool>,
75  pub preamble:     Option<String>,
76  pub postamble:    Option<String>,
77}
78
79pub trait DigestionAPI {
80  fn initialize_singletons(&mut self, preloads: Vec<String>) -> Result<()>;
81  fn digest(
82    &mut self,
83    request: String,
84    preamble: Option<String>,
85    postamble: Option<String>,
86    mode: Option<DigestionMode>,
87    no_init: bool,
88  ) -> Result<Digested>;
89  fn digest_setup(
90    &mut self,
91    request: String,
92    preamble: Option<String>,
93    postamble: Option<String>,
94    mode: Option<DigestionMode>,
95  ) -> Result<String>;
96  fn digest_file(&mut self, request: String, options: DigestionOptions) -> Result<Digested>;
97  fn digest_internal(&mut self) -> Result<Digested>; // used to be "finishDigestion"
98  fn convert_file(&mut self, filepath: String) -> Result<Document>;
99  /// Streaming (fragmented) conversion: interleaved digest→build with
100  /// spill-to-disk, a streaming pass 2, and placeholder-spliced assembly.
101  /// The Perl-parity eager path is `digest` + `convert_document`; this is the
102  /// sanctioned bounded-memory divergence (OXIDIZED_DESIGN), activated only
103  /// via `Config::streaming`.
104  fn convert_streaming(
105    &mut self,
106    request: String,
107    preamble: Option<String>,
108    postamble: Option<String>,
109    mode: Option<DigestionMode>,
110    budget: usize,
111  ) -> Result<Document>;
112  fn convert_document(&mut self, digested: Digested) -> Result<Document>;
113  // Mocks
114  /// Load preamble content. Perl: Core.pm loadPreamble
115  fn load_preamble(&mut self, preamble: String) {
116    let content = if preamble == "standard_preamble.tex" {
117      "literal:\\documentclass{article}\\begin{document}".to_string()
118    } else {
119      preamble
120    };
121    input_content(&content, InputOptions::default()).ok();
122  }
123  /// Load postamble content. Perl: Core.pm loadPostamble
124  fn load_postamble(&mut self, postamble: String) {
125    let content = if postamble == "standard_postamble.tex" {
126      "literal:\\end{document}".to_string()
127    } else {
128      postamble
129    };
130    input_content(&content, InputOptions::default()).ok();
131  }
132}
133
134/// Parse a preload spec into `(name, ext, options)`.
135///
136/// Mirrors Perl `Core.pm:initializeState` (regexes
137/// `s/^\[([^\]]*)\]//` then `s/\.(\w+)$//`): the option bracket
138/// comes at the *front*, e.g. `[ids,mathlexemes]latexml.sty`.
139/// Defaults to `ext = "sty"` when the spec has no `.<ext>` suffix.
140pub(crate) fn parse_preload_spec(preload: &str) -> (String, String, Vec<String>) {
141  let (base, options) = match preload
142    .strip_prefix('[')
143    .and_then(|rest| rest.find(']').map(|end| (&rest[..end], &rest[end + 1..])))
144  {
145    Some((opts_str, rest)) => {
146      let opts: Vec<String> = opts_str
147        .split(',')
148        .map(|s| s.trim().to_string())
149        .filter(|s| !s.is_empty())
150        .collect();
151      (rest.to_string(), opts)
152    },
153    None => (preload.to_string(), vec![]),
154  };
155  let (name, ext) = match base.rfind('.') {
156    Some(pos) => (base[..pos].to_string(), base[pos + 1..].to_string()),
157    None => (base.clone(), String::from("sty")),
158  };
159  (name, ext, options)
160}
161
162/// One guarded digestion step: `digest_next_body(None)` under the salvage
163/// policy `digest_internal` has always applied (extracted verbatim so the
164/// eager loop and the streaming driver share ONE policy). `Ok(true)` =
165/// continue; `Ok(false)` = a Fatal was recovered (announced + latched, boxes
166/// salvaged) and digestion must stop; `Err` = a resource fatal that must not
167/// be recovered.
168/// Can we create files in `dir`? Probes by creating and removing a uniquely
169/// named entry — the only answer that is true for the actual operation, since
170/// permission bits, read-only mounts, and full filesystems all present
171/// differently and `metadata().permissions()` sees none of them reliably.
172fn dir_is_writable(dir: &Path) -> bool {
173  let probe = dir.join(format!(".latexml-writable-{}", std::process::id()));
174  match std::fs::File::create(&probe) {
175    Ok(_) => {
176      let _ = std::fs::remove_file(&probe);
177      true
178    },
179    Err(_) => false,
180  }
181}
182
183fn digest_step_guarded(boxes: &mut Vec<Digested>) -> Result<bool> {
184  match stomach::digest_next_body(None) {
185    Ok(next_bodies) => {
186      boxes.extend(next_bodies);
187      Ok(true)
188    },
189    Err(e) => {
190      // Re-raise MemoryBudget / wall-clock Timeout (Convert) errors:
191      // those are *resource* failures, not recoverable digestion
192      // hiccups. Catching them here would silently produce empty
193      // output for a runaway-loop paper, masking a real bug and
194      // inflating canvas pass rates with empty conversions.
195      // R35.A: ensure pathological inputs fail loudly (exit 1+)
196      // rather than silently turning into a zero-byte HTML.
197      use latexml_core::common::error::{ErrorCategory, ErrorTarget};
198      // NOTE the target discrimination on `MemoryBudget`, which is
199      // deliberate rather than an oversight. `Timeout`-target is the RSS
200      // fuse: real resident memory is already at the ceiling, so
201      // continuing means allocating straight into an OOM — there is
202      // nothing to do but stop. `Stomach`-target is the box-list ceilings
203      // (`box_count_cap` / `box_bytes_budget` / boxing depth), where the
204      // salvage below CLEARS the offending accumulation and therefore
205      // itself frees the memory; recovering there is safe precisely
206      // because the hard RSS backstop above stays non-recoverable
207      // underneath it. So the stomach's memory guards stay on the recovery
208      // path — a graceful end with as much of the document as was already
209      // digested, and the Fatal announced and latched below.
210      if matches!(
211        (&e.target, &e.category),
212        (ErrorTarget::Timeout, ErrorCategory::MemoryBudget)
213          | (ErrorTarget::Timeout, ErrorCategory::Convert)
214          | (ErrorTarget::Timeout, ErrorCategory::TokenLimit)
215          | (ErrorTarget::Timeout, ErrorCategory::PushbackLimit)
216      ) {
217        emit_warn(
218          "recovery",
219          "digest_internal",
220          &format!(
221            "digest_internal: resource failure ({:?}/{:?}) — not recovering",
222            e.target, e.category
223          ),
224        );
225        return Err(e);
226      }
227      // The Err that landed here was raised at Fatal level. We recover
228      // BOXES from it (below) — Perl `finishDigestion` L219-220 — but a
229      // Fatal-level raise stays FATAL in the document's reported outcome:
230      // salvaging content is not licence to reclassify the verdict, and
231      // there is deliberately no auto-upgrade to Error severity here (user
232      // policy 2026-07-28). The one sanctioned demotion in this codebase is
233      // the bibliography's explicit `DEMOTE_FATALS` (`error.rs`), which is
234      // opt-in and scoped.
235      //
236      // `log_fatal()` is the single seam that does BOTH halves: it emits
237      // the standard `Fatal:<target>:<category>` line AND latches
238      // `LogStatus::Fatal`. This used to be a hand-rolled `log::error!`
239      // with a `Fatal:`-prefixed target, avoiding `log_fatal` for fear of
240      // "double-incrementing the counter" — unfounded, since the fatal
241      // status is a sticky BOOL (`error.rs` `note_status`, guarded by
242      // `fatal_status_is_sticky_and_returns_1`). The hand-rolled call used
243      // the raw `log`-crate macro, which never reaches `note_status`, so
244      // guard fatals raised as a plain `Err` by `stomach::check_timeout`
245      // (rather than through `Fatal!`) were never counted at all: the log
246      // carried a `Fatal:` line while the run summarised as `Conversion
247      // complete: No obvious problems`, status code 0 — "ok" to cortex.
248      // Guard: `101_fatal_salvages_partial_document`.
249      e.log_fatal();
250      emit_warn(
251        "recovery",
252        "digest_internal",
253        &format!("digest_internal: error during recovery digestion: {:?}", e),
254      );
255      // Recover what the failed body already digested. Without this the
256      // "still produce partial output" intent above only worked when the
257      // failure landed in a LATER body — a Fatal inside the FIRST one left
258      // `boxes` empty and the run wrote a 39-byte empty document, losing a
259      // whole paper to one bad construct (arXiv:2508.07407 / ar5iv #556,
260      // one pathological `\tikz` picture).
261      //
262      // Scoped to the STOMACH box-cycle guard, deliberately. That guard
263      // fires while the token stream is still healthy — one construct is
264      // piling up boxes — so the surrounding document is sound and worth
265      // keeping, and the innermost level (the 50k-box repeating window) is
266      // exactly the construct to drop.
267      //
268      // It is NOT extended to the gullet's `Timeout:Recursion`, where the
269      // TOKEN stream is the thing looping: measured on arXiv:2605.25400,
270      // salvaging there revived a poisoned state that re-entered the same
271      // loop during build and turned an 8.7 s fatal into a 2 m 12 s
272      // wall-clock timeout writing a ZERO-byte file — strictly worse than
273      // the 39-byte stub, for a 1.7 KB gain on the one paper it helped.
274      // Same reasoning bars `TooManyErrors`; widening to either needs its
275      // own measurement, not an assumption that more salvage is better.
276      if matches!(e.target, ErrorTarget::Stomach) {
277        let salvaged = stomach::salvage_pending_box_lists(true);
278        if !salvaged.is_empty() {
279          emit_info(
280            "recovery",
281            "digest_internal",
282            &format!(
283              "digest_internal: salvaged {} box(es) digested before the fatal",
284              salvaged.len()
285            ),
286          );
287          boxes.extend(salvaged);
288        }
289      }
290      Ok(false)
291    },
292  }
293}
294
295/// The document head shared by the eager and streaming paths: a fresh
296/// [`Document`] with the schema model loaded and the preload PIs inserted
297/// (the front half of `convert_document`, extracted verbatim).
298fn build_document_head(preloads: &[String]) -> Result<Document> {
299  let mut document = Document::new();
300  {
301    // TODO: Can we disentangle the ownership to avoid the clone?
302    let paths_stored = state::get_search_paths();
303    let schema_paths = paths_stored
304      .iter()
305      .map(String::as_str)
306      .collect::<Vec<&str>>();
307    let default_model_load = model::with_schema_data(|schema_opt| match schema_opt {
308      None => true,
309      Some(v) => v.last() == Some(&pin!("LaTeXML")),
310    });
311    if default_model_load {
312      // Compile-time load of model AND indirect model. Single
313      // shared instantiation lives at `crate::load_latexml_default_model`
314      // so LTO can keep exactly one `_ModelLoader::build_model` in
315      // the final binary (~600 KiB per copy otherwise).
316      crate::load_latexml_default_model();
317    } else {
318      // Eager-load at runtime
319      model::load_schema(schema_paths.as_slice())?; // If needed?
320    }
321    if state::has_search_paths() {
322      {
323        if state::lookup_bool("INCLUDE_COMMENTS") {
324          let paths_string = state::with_search_paths(|paths| {
325            paths
326              .iter()
327              .map(String::as_str)
328              .collect::<Vec<&str>>()
329              .join(",")
330          });
331          let attributes = map! {s!("searchpaths") => paths_string};
332          document.insert_pi("latexml", Some(attributes))?;
333        }
334      }
335    }
336  }
337
338  for preload in preloads {
339    if preload.ends_with(".pool") {
340      continue;
341    }
342    // Perl `Core.pm` L268-277 rewrites `$preload` IN PLACE with `s///`, so
343    // the option bracket and the `.cls`/`.sty` suffix are gone from the
344    // string that becomes the attribute value, and the captured options ride
345    // along as a second attribute. `Regex::replace_all` RETURNS a new string
346    // rather than mutating its input, so discarding the result — as this loop
347    // did until 2026-07-29 — stripped nothing at all:
348    // `--preload=[twocolumn,11pt]article.cls` emitted
349    // `<?latexml class="[twocolumn,11pt]article.cls"?>` where Perl emits
350    // `<?latexml class="article" options="twocolumn,11pt"?>`.
351    //
352    // Deliberately NOT routed through `parse_preload_spec` above: that
353    // splits on the LAST `.` and so would also eat a non-package extension
354    // (`--preload=mystyle.tex` -> `package="mystyle"`), while Perl strips
355    // only the two literal suffixes and leaves anything else attached. It
356    // also trims/drops empty options, where Perl passes `$1` verbatim.
357    let mut spec: &str = preload;
358    let mut options: &str = "";
359    if let Some(bracket) = LATEX_OPTION_REGEX.captures(spec) {
360      options = bracket.get(1).map_or("", |m| m.as_str());
361      spec = &spec[bracket.get(0).map_or(0, |m| m.end())..];
362    }
363    let mut attributes: HashMap<String, String> = HashMap::default();
364    // Perl's `($options ? (options => $options) : ())`: an empty bracket
365    // (`[]name.sty`) is falsy, so it contributes no attribute at all.
366    if !options.is_empty() {
367      attributes.insert(s!("options"), options.to_string());
368    }
369    if let Some(class) = spec.strip_suffix(".cls") {
370      attributes.insert(s!("class"), class.to_string());
371    } else {
372      attributes.insert(
373        s!("package"),
374        spec.strip_suffix(".sty").unwrap_or(spec).to_string(),
375      );
376    }
377    document.insert_pi("latexml", Some(attributes))?;
378  }
379  Ok(document)
380}
381
382/// Load the source-adjacent `.latexml` rewrite-rules file, if present.
383/// Perl does this during initialization; we do it post-build so the rules can
384/// compile against the built document. Extracted so the streaming path can
385/// load rules BEFORE its pass 2 (fragments must see the complete rule set)
386/// without `finish_document` loading them a second time.
387fn load_source_latexml_rules() {
388  // Load .latexml file if it exists alongside the source .tex file.
389  // Perl does this automatically during initialization; we do it post-build
390  // so the rewrite rules can be compiled against the built document.
391  if let Some(Stored::String(source_sym)) = state::lookup_value("SOURCEFILE") {
392    let source_path = arena::with(source_sym, |s| s.to_string());
393    // Replace .tex extension with .latexml
394    let latexml_path = if source_path.ends_with(".tex") {
395      source_path.replace(".tex", ".latexml")
396    } else {
397      format!("{}.latexml", source_path)
398    };
399    if Path::new(&latexml_path).exists() {
400      let _ = load_latexml_file(&latexml_path);
401    }
402  }
403}
404
405/// The whole-document tail shared by both paths: rewrites, `\lxDeclare`
406/// application, math parsing, finalize, XMTok-id cleanup (the back half of
407/// `convert_document`, extracted verbatim). In streaming mode this runs on
408/// the live SPINE — spilled fragments received the same phases fragment-by-
409/// fragment in `streaming_pass2` beforehand.
410fn finish_document(document: &mut Document) -> Result<()> {
411  let has_rewrites = state::has_value("DOCUMENT_REWRITE_RULES");
412  if has_rewrites {
413    let _gp_rewrite = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::Rewrite);
414    note_begin("Rewriting");
415    document.mark_xmnode_visibility()?;
416    document.load_labels_for_rewrite()?;
417    // TODO: What is the right way to do rewrites in a daemon-safe manner?
418    if let Some(Stored::VecDequeStored(rules)) = state::remove_value("DOCUMENT_REWRITE_RULES")
419      && let Some(root) = document.get_document().get_root_element()
420    {
421      apply_rewrite_rules(document, rules, &root)?;
422    }
423    note_end("Rewriting");
424  }
425
426  // Apply \lxDeclare declarations: set roles/names/meanings on matching XMTok elements.
427  // Must run BEFORE math parsing so the parser sees the updated roles.
428  apply_lx_declarations(document, None);
429
430  if !state::get_nomathparse_flag() {
431    // Telemetry: count formulae and time the whole Marpa parse pass.
432    // Per-formula bucket histogram requires per-call instrumentation
433    // inside latexml_math_parser::parser::parse_math; deferred.
434    let xmath_count = document.findnodes("//ltx:XMath", None).len() as u32;
435    // ADD, not set: `finish_document` runs on the streaming SPINE *after*
436    // `streaming_pass2` has already counted every spilled fragment's formulae,
437    // so a plain `set` here would clobber the segment tallies and report only
438    // the spine's own — near zero on a document that spilled most of itself.
439    // Eager is unaffected: the counter starts at 0 and this runs once.
440    latexml_core::telemetry::add_formulae(xmath_count);
441    let _gp = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::MathParse);
442    let mut parser = MathParser::default();
443    parser.parse_math(document)?;
444    drop(_gp);
445    // Post-parse: mark failed XMath nodes as unparsed.
446    // The parser's parse_kludge already handles OPEN/CLOSE wrapping + script attachment
447    // (parse_kludgeScripts_rec), so we only need to add the unparsed CSS class here.
448    if !parser.failed_xmath_ids.is_empty() {
449      for mut math_node in document.findnodes("descendant-or-self::ltx:Math[not(@text)]", None) {
450        for xmath_child in document.findnodes("ltx:XMath", Some(&math_node)) {
451          if parser.failed_xmath_ids.contains(&xmath_child.to_hashable()) {
452            document.add_class(&mut math_node, "ltx_math_unparsed")?;
453            break;
454          }
455        }
456      }
457    }
458    // Renumber xml:ids inside parsed XMath subtrees to be sequential in document
459    // order. The Marpa parser explores multiple parse alternatives, consuming ID
460    // counter slots for pruned nodes. This pass reassigns IDs post-parse.
461    renumber_math_ids(document);
462    // Fill in \ltx@count@parses markers with actual parse tree counts.
463    // Each marker is <ltx:text _parsetrees_marker="true">0</ltx:text>.
464    // Find the preceding ltx:Math[@_parsetrees] and copy the count.
465    let markers = document.findnodes("//*[@_parsetrees_marker='true']", None);
466    for mut marker in markers {
467      let count = {
468        let preceding = document.findnodes("preceding::ltx:Math[@_parsetrees][1]", Some(&marker));
469        preceding
470          .into_iter()
471          .last()
472          .and_then(|m| m.get_attribute("_parsetrees"))
473          .unwrap_or_else(|| "0".to_string())
474      };
475      // Replace the text content with the actual count
476      for mut child in marker.get_child_nodes() {
477        child.unlink_node();
478      }
479      let _ = marker.append_text(&count);
480      // Remove the marker attribute
481      let _ = marker.remove_attribute("_parsetrees_marker");
482    }
483  }
484
485  // #683 (xworld21): persist NOMINAL_FONT_SIZE as a
486  // `<?latexml nominal-font-size="X"?>` processing instruction when it differs
487  // from the 10pt default, so post-processing can size font-relative (em)
488  // external SVGs correctly (an `em` is `NOMINAL_FONT_SIZE`pt, not always 10pt).
489  // Perl does not emit this — NOMINAL_FONT_SIZE is digestion-only (`DEFSIZE`),
490  // so this is beyond-Perl. Only a0poster (25), the NNpt class options, and
491  // BookML move it off 10, so a normal document's output stays byte-identical
492  // (no new PI). `insert_pi` places it before the root, alongside the other
493  // `<?latexml …?>` metadata PIs (class/package/graphicspath).
494  if let Some(nominal) = state::lookup_float("NOMINAL_FONT_SIZE")
495    && (nominal.0 - 10.0).abs() > 1e-6
496  {
497    let mut attrs = HashMap::default();
498    attrs.insert(String::from("nominal-font-size"), nominal.0.to_string());
499    document.insert_pi("latexml", Some(attrs))?;
500  }
501
502  note_begin("Finalizing");
503  document.finalize()?;
504  note_end("Finalizing");
505  // Perl core produces role="UNKNOWN" for single-letter math tokens.
506  // Per-document .latexml files set role="ID" via DefMathRewrite BEFORE parsing.
507  // We do NOT apply a blanket conversion — roles are set by rewrite rules only.
508  // Cleanup unreferenced xml:ids on XMTok elements generated by the math parser.
509  // Must run after finalize (which includes prune_xmduals that may transfer ids).
510  document.cleanup_unreferenced_xmtok_ids();
511  Ok(())
512}
513
514/// Compile and invoke a set of `DefRewrite`/`DefMathRewrite` rules against
515/// `root` (extracted verbatim from the eager Rewriting phase). The streaming
516/// pass 2 calls this once per FRAGMENT with a snapshot of the rule set — the
517/// S2 census showed the production corpus is subtree-local, so per-fragment
518/// application is equivalent; `label:`-scoped rules resolve through the
519/// fragment's `rewrite_labels`, pre-merged with the spilled-label index.
520fn apply_rewrite_rules(
521  document: &mut Document,
522  rules: std::collections::VecDeque<Stored>,
523  root: &libxml::tree::Node,
524) -> Result<()> {
525  // Step 1: copy the rules locally through Rc, to be able to invoke them with mutable
526  // state. (TODO: obviously, this could be avoided if they never needed mutable
527  // state. When do they?)
528  let mut rewrites = Vec::new();
529  for rule in rules {
530    if let Stored::Rewrite(mut rewrite_rule) = rule {
531      rewrite_rule.compile_clauses(document);
532      rewrites.push(rewrite_rule);
533    }
534  }
535  // 31 rules compiled for declare test; XPath matching issue prevents application
536  // Step 2: invoke the rewrite rules
537  // R35.D instrumentation: print per-rule timing if
538  // LATEXML_REWRITE_TIMING=1. Logs BEFORE the rule runs so we can
539  // identify the rule that hangs (the timeout watchdog kills
540  // mid-rule otherwise).
541  let trace_all = std::env::var_os("LATEXML_REWRITE_TIMING").is_some();
542  let n_rules = rewrites.len();
543  for (idx, mut rewrite_rule) in rewrites.into_iter().enumerate() {
544    // Build a useful one-line hint from the rule's options. The
545    // Debug impl on RewriteOptions is `<RewriteOptions>` only,
546    // so reach into the fields directly.
547    let opts = &rewrite_rule.options;
548    let mut xpath_hint = format!(
549      "select={:?} xpath={:?} regexp={:?} scope={:?} label={:?} clauses={}",
550      opts
551        .select
552        .as_deref()
553        .map(|s| s.chars().take(60).collect::<String>()),
554      opts
555        .xpath
556        .as_deref()
557        .map(|s| s.chars().take(60).collect::<String>()),
558      opts
559        .regexp
560        .as_deref()
561        .map(|s| s.chars().take(60).collect::<String>()),
562      opts.scope.as_ref().map(|_| "<scope>"),
563      opts.label.as_deref(),
564      rewrite_rule.clauses.len(),
565    );
566    // Dump compiled clauses by op + pattern preview (helps when
567    // the options struct itself is empty after compile_clauses
568    // moved them into the clauses vec).
569    for (ci, c) in rewrite_rule.clauses.iter().enumerate() {
570      use std::fmt::Write;
571      let _ = write!(
572        xpath_hint,
573        "\n    [{ci}] op={:?} pat={:?}",
574        c.op,
575        match &c.pattern {
576          latexml_core::rewrite::RewritePattern::String(s) =>
577            format!("Str({})", s.chars().take(120).collect::<String>()),
578          latexml_core::rewrite::RewritePattern::Tokens(_) => "Tokens(..)".into(),
579          latexml_core::rewrite::RewritePattern::Closure(_) => "Closure(..)".into(),
580          latexml_core::rewrite::RewritePattern::NodeList(n) => format!("NodeList({})", n.len()),
581          _ => "??".into(),
582        }
583      );
584    }
585    if trace_all {
586      eprintln!(
587        "[rewrite-timing] rule #{}/{} START :: {}",
588        idx, n_rules, xpath_hint
589      );
590      // Flush stderr so it appears even if the rule hangs
591      use std::io::Write;
592      let _ = std::io::stderr().flush();
593    }
594    let started = std::time::Instant::now();
595    rewrite_rule.invoke(document, root)?;
596    let elapsed = started.elapsed();
597    if trace_all {
598      eprintln!(
599        "[rewrite-timing] rule #{}/{} END {:.2?}",
600        idx, n_rules, elapsed
601      );
602    } else if elapsed > std::time::Duration::from_secs(5) {
603      eprintln!(
604        "[rewrite-timing] rule #{}/{} SLOW {:.2?} :: {}",
605        idx, n_rules, elapsed, xpath_hint
606      );
607    }
608  }
609  Ok(())
610}
611
612/// Streaming pass 2: give every spilled fragment the SAME whole-document tail
613/// the spine gets — rewrites, `\lxDeclare`, math parsing, per-fragment
614/// finalize — then store its final serialized text for the assembly splice.
615/// Runs after digestion finished (so the rewrite-rule set is complete) and
616/// before `finish_document` consumes the rules for the spine.
617/// Root-level id counters (`_ID_counter_*` attrs) must run document-wide:
618/// eager mints `id1..idN` across the whole document in one walk, so pass 2
619/// seeds each fragment's parse wrapper with the counters carried out of the
620/// previous one (`counters` in/out), and the caller seeds the spine's tail
621/// from the final state (sweep witness tests/alignment/plainmath.tex, where
622/// every fragment restarted at `id1`).
623fn streaming_pass2(
624  store: &mut latexml_core::sxml::SegmentStore,
625  index: &latexml_core::sxml::FragmentIndex,
626  node_fonts: &rustc_hash::FxHashMap<u64, latexml_core::common::font::Font>,
627  counters: &mut Vec<(String, String)>,
628) -> Result<()> {
629  use latexml_core::common::error::{ErrorCategory, ErrorTarget};
630  // A non-consuming snapshot of the rules: `finish_document` will consume the
631  // live entry for the spine afterwards.
632  let rules_opt = match state::lookup_value("DOCUMENT_REWRITE_RULES") {
633    Some(Stored::VecDequeStored(rules)) => Some(rules),
634    _ => None,
635  };
636  let nomath = state::get_nomathparse_flag();
637  let segments: Vec<_> = store.ids().collect();
638  // The spilled-label index is the SAME for every fragment, so build it once
639  // and share it (Document::rewrite_labels_shared, consulted on a local miss).
640  // Copying it into each fragment's own map instead was quadratic: 28,068
641  // labels × 459,579 segments on the 131 MB witness = 12.9 billion String
642  // allocations, and it dominated pass 2.
643  let shared_labels: Option<Rc<rustc_hash::FxHashMap<String, String>>> =
644    rules_opt.is_some().then(|| {
645      Rc::new(
646        index
647          .labels()
648          .map(|(label, id)| (label.to_string(), id.to_string()))
649          .collect::<rustc_hash::FxHashMap<_, _>>(),
650      )
651    });
652  // Rate-limited exactly like the pass-1 telemetry next door (see the
653  // `is_power_of_two() || is_multiple_of(65536)` gate in `convert_streaming`):
654  // the 131 MB witness spills 459,579 segments, and three ungated `info!` per
655  // segment wrote 1.38 M lines — 44% of a 161 MB log — into the in-RAM
656  // LOG_BUFFER that pass 1 exists to keep bounded. Dense at the start so short
657  // runs and tests still see every line, logarithmic after, 64k floor.
658  //
659  // The ARGUMENTS must stay inside the gate, not just the macro: each probe
660  // reads /proc/self/status, and the `parsed` line re-reads the whole segment
661  // off disk purely to print its size.
662  fn telemetry_due(n: usize) -> bool { n.is_power_of_two() || n.is_multiple_of(65536) }
663  for (seg_idx, seg) in segments.into_iter().enumerate() {
664    let due = telemetry_due(seg_idx + 1);
665    if store.is_retired(seg) {
666      // Inlined into an enclosing segment; its content is processed there.
667      continue;
668    }
669    let meta = store.meta(seg)?.clone();
670    // Segments are bounded (one spill run), so plain parsing is as bounded as
671    // streaming — and unlike `xmlTextReaderExpand`, it does not mint the
672    // libxml2 "default" namespace prefix for default-namespace content
673    // (`append_clone`'s doc-comment records the same trap). The streaming
674    // TextReader stays reserved for genuinely unbounded files (the post
675    // half's pass A over the whole core XML).
676    let frag_xml = libxml::parser::Parser::default()
677      .parse_string(&store.wrapped_segment(seg)?)
678      .map_err(|e| Error {
679        target:   ErrorTarget::Internal,
680        category: ErrorCategory::Libxml,
681        message:  s!("cannot re-parse staged segment {seg}: {e}"),
682      })?;
683    let mut out = String::new();
684    {
685      let mut frag = Document::from_xml_document(frag_xml, node_fonts.clone())?;
686      if due {
687        emit_info(
688          "streaming",
689          "progress",
690          &format!(
691            "streaming pass2: segment {seg} ({} KB) parsed; RSS ~{} MB",
692            store.read_segment(seg).map(|t| t.len() / 1024).unwrap_or(0),
693            latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024,
694          ),
695        );
696      }
697      frag.scoped_rules_strict = true;
698      // A fragment re-emits the (nested) placeholders it contains; only the
699      // final assembly resolves them.
700      frag.literal_placeholders = true;
701      // An ancestor-scoped rewrite covers the whole fragment (field docs).
702      frag.fragment_ancestor_ids = meta.ancestors.iter().cloned().collect();
703      // Judge the parse wrapper as the segment's REAL parent in schema
704      // decisions (empty-`ltx:text` collapse etc.) — see the field docs.
705      frag.fragment_parent_qname = meta.parent.as_deref().map(arena::pin);
706      // Seed the wrapper root with the carried root-level id counters, so
707      // fragment id minting continues the document-wide sequence.
708      if let Some(mut root) = frag.get_document().get_root_element() {
709        for (key, value) in counters.iter() {
710          let _ = root.set_attribute(key, value);
711        }
712      }
713      // No strip needed: pass 1 serializes segments FLAT (`spill_flat`), so
714      // the indentation text nodes this used to delete are never created.
715      // `strip_indentation_whitespace` unlinked them, and unlink does not
716      // free — they were orphaned for the fragment's lifetime.
717      // Restore empty text children the parse could not represent (spill-time
718      // `_lx_empty_text` markers — see `spill_run`): `<p></p>` must not
719      // collapse to `<p/>` across the round-trip.
720      for mut marked in frag.findnodes("//*[@_lx_empty_text]", None) {
721        let _ = marked.remove_attribute("_lx_empty_text");
722        if marked.get_child_nodes().is_empty() {
723          // `append_text("")` is a no-op (the fork guards on len > 0), so
724          // create a real text node and then empty it in place.
725          let _ = marked.append_text("x");
726          if let Some(mut text) = marked.get_first_child() {
727            let _ = text.set_content("");
728          }
729        }
730      }
731      if let Some(rules) = &rules_opt {
732        // `Phase::Rewrite` is taken in `finish_document`, which only ever runs
733        // on the SPINE — pass 2 calls `apply_rewrite_rules` directly, so every
734        // fragment's rewrite work was unattributed.
735        let _gp_rewrite = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::Rewrite);
736        frag.mark_xmnode_visibility()?;
737        frag.load_labels_for_rewrite()?;
738        // Share the prebuilt index rather than copying it in; a frag-local
739        // label still wins, exactly as the `or_insert_with` copy ensured.
740        frag.rewrite_labels_shared = shared_labels.clone();
741        if let Some(root) = frag.get_document().get_root_element() {
742          apply_rewrite_rules(&mut frag, rules.clone(), &root)?;
743        }
744      }
745      apply_lx_declarations(&mut frag, meta.section_id.as_deref());
746      if !nomath {
747        // Only probed when the line will actually be emitted — this read used
748        // to sit outside the macro, so its /proc cost was paid on every
749        // segment even at `--quiet`.
750        let rss0 = if due {
751          latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024
752        } else {
753          0
754        };
755        // Same telemetry the EAGER path records (see the `//ltx:XMath` count
756        // and `Phase::MathParse` guard above). Streaming recorded neither,
757        // while `record_math_parse` inside the parser kept firing — so a
758        // streamed job's telemetry.json showed thousands of
759        // `math_parse_attempts` against `formulae: 0` and a near-zero
760        // `phase_math_parse_us`, systematically, on the biggest papers.
761        // Accumulated across segments, since pass 2 parses each separately.
762        latexml_core::telemetry::add_formulae(frag.findnodes("//ltx:XMath", None).len() as u32);
763        let _gp = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::MathParse);
764        let mut parser = MathParser::default();
765        parser.parse_math(&mut frag)?;
766        drop(_gp);
767        if due {
768          emit_info(
769            "streaming",
770            "progress",
771            &format!(
772              "streaming pass2: segment {seg} math done; RSS {} -> {} MB; arena {} syms",
773              rss0,
774              latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024,
775              arena::len(),
776            ),
777          );
778        }
779        // Mirror the eager tail: mark failed formulae, renumber math ids
780        // (per-Math, so fragment-local by construction).
781        if !parser.failed_xmath_ids.is_empty() {
782          for mut math_node in frag.findnodes("descendant-or-self::ltx:Math[not(@text)]", None) {
783            for xmath_child in frag.findnodes("ltx:XMath", Some(&math_node)) {
784              if parser.failed_xmath_ids.contains(&xmath_child.to_hashable()) {
785                frag.add_class(&mut math_node, "ltx_math_unparsed")?;
786                break;
787              }
788            }
789          }
790        }
791        renumber_math_ids(&mut frag);
792      }
793      // The per-fragment share of finalize: XMRef/XMDual pruning and the
794      // font/bookkeeping walk. The root-only passes (RDFa prefixes, namespace
795      // declarations, schema PI) belong to the SPINE root and must NOT touch
796      // a fragment root — they would change its serialization.
797      frag.prune_dangling_split_xmrefs()?;
798      frag.prune_xmduals()?;
799      if let Some(mut root) = frag.get_document().get_root_element() {
800        frag.finalize_subtree(&mut root)?;
801      }
802      frag.cleanup_unreferenced_xmtok_ids();
803      // The parsed root is the `_lxfragment` wrapper; the spilled subtrees are
804      // its children. Serialize each at the recorded position parameters.
805      if let Some(root) = frag.get_document().get_root_element() {
806        let _gp_ser = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::Serialize);
807        for child in root.get_child_nodes() {
808          out.push_str(&frag.serialize_aux(&child, meta.depth, meta.noindent, false));
809        }
810        // Carry the wrapper's root-level id counters to the next fragment
811        // (finalize deliberately leaves the wrapper's bookkeeping attrs in
812        // place — see finalize_rec's PostWork).
813        counters.clear();
814        for (key, value) in root.get_attributes() {
815          if key.starts_with("_ID_counter") {
816            counters.push((key, value));
817          }
818        }
819      }
820    }
821    store.finalize_segment(seg, &out)?;
822    if due {
823      emit_info(
824        "streaming",
825        "progress",
826        &format!(
827          "streaming pass2: segment {seg} finalized+dropped; RSS ~{} MB",
828          latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024
829        ),
830      );
831    }
832  }
833  Ok(())
834}
835
836impl DigestionAPI for Core {
837  fn initialize_singletons(&mut self, preloads: Vec<String>) -> Result<()> {
838    // reset the error REPORT singleton
839    error::initialize_report();
840    // Per-conversion notice state in the math parser (the persistent
841    // cortex_worker converts many documents per process).
842    latexml_math_parser::reset_conversion_notices();
843    // Same reason: the only other telemetry reset is `take()`, which the
844    // binaries skip entirely when no telemetry sink is configured.
845    latexml_core::telemetry::reset();
846    // reset localized variables (if_frames, current_token, align state, etc.)
847    latexml_core::common::local_assignments::initialize_localized();
848    // now handle conversion state
849    gullet::initialize_gullet();
850    stomach::initialize_stomach();
851    // should we reset the model also?
852    model::initialize_model();
853    // let paths = state::search_paths;
854    let dump_path = LATEXML_DUMP.clone();
855    // Publish our version (Perl's `$LaTeXML::VERSION`) so bindings can read it —
856    // e.g. the Rhai `LaTeXMLVersion()` binding, and the XSLT `LATEXML_VERSION`
857    // parameter. Global so it survives the whole conversion.
858    state::assign_value("LATEXML_VERSION", LATEXML_VERSION, Some(Scope::Global));
859    state::assign_value("InitialPreloads", true, Some(Scope::Global));
860    for preload in preloads {
861      let (name, ext, options) = parse_preload_spec(&preload);
862      let handleoptions = ext == "sty" || ext == "cls";
863      // Pass package options via state (Perl: \PassOptionsToPackage equivalent).
864      // Match `\PassOptionsToPackage` at latex_constructs.rs L3838-3842: push the
865      // `Vec<String>` through `push_value` so it lands as a `Stored::Strings`
866      // batch inside the `opt@<name>.<ext>` `VecDequeStored`. The batch shape is
867      // what `collect_syms` (binding/content.rs L1157) flattens when
868      // `\ProcessOptions*` enumerates declared options; storing a single
869      // comma-joined `Stored::String("opt1,opt2")` instead silently bypasses
870      // every `DeclareOption!` site, so e.g. dvipsnames/svgnames/x11names
871      // palettes never load — visible as `Error:unexpected:Apricot Can't find
872      // color named 'Apricot'; assuming Black` on a `[dvipsnames]color.sty`
873      // preload.
874      if !options.is_empty() {
875        let opt_key = format!("opt@{name}.{ext}");
876        state::push_value(&opt_key, options)?;
877      }
878      input_definitions(&name, InputDefinitionOptions {
879        extension: Some(ext.into()),
880        handleoptions,
881        ..InputDefinitionOptions::default()
882      })?;
883    }
884    state::assign_value("InitialPreloads", false, Some(Scope::Global));
885
886    // Load kernel dump AFTER pools (provides TeX/LaTeX macros the pools skipped).
887    if let Some(ref dump_path) = dump_path {
888      let path = Path::new(dump_path);
889      if path.exists() {
890        // Rust-native tab-separated format (from --init mode). The
891        // Perl-format `dump_loader` was deleted 2026-04-18 (dead code —
892        // we never consumed Perl-generated dumps).
893        let result = latexml_core::dump_reader::load_native_dump(path);
894        match result {
895          Ok(count) => {
896            eprintln!(
897              "[latexml-oxide] Loaded {} kernel definitions from {}",
898              count,
899              path.display()
900            );
901          },
902          Err(e) => {
903            eprintln!("[latexml-oxide] Warning: failed to load dump: {}", e);
904          },
905        }
906      }
907    }
908    Ok(())
909  }
910
911  // TODO: We should choose between this function or digest_file, rather than implement twice,
912  // right?
913  fn digest(
914    &mut self,
915    request: String,
916    preamble: Option<String>,
917    postamble: Option<String>,
918    mode: Option<DigestionMode>,
919    _no_init: bool,
920  ) -> Result<Digested> {
921    let digestion_note = self.digest_setup(request, preamble, postamble, mode)?;
922    let list = self.digest_internal()?;
923    note_end(&digestion_note);
924    Ok(list)
925  }
926
927  /// The input-side half of [`DigestionAPI::digest`]: canonicalize the
928  /// request, seed SOURCEFILE/SOURCEDIRECTORY/SEARCHPATHS/`\jobname`, queue
929  /// postamble/source/preamble on the gullet. Returns the progress-note label
930  /// the caller must `note_end` when digestion completes. Shared by the eager
931  /// path and `convert_streaming` (which drives digestion itself,
932  /// fragment-by-fragment).
933  fn digest_setup(
934    &mut self,
935    request: String,
936    preamble: Option<String>,
937    postamble: Option<String>,
938    mode: Option<DigestionMode>,
939  ) -> Result<String> {
940    let mut _ext = match &mode {
941      Some(m) => Some(m.extension()),
942      None => Some(DigestionMode::TeX.extension()),
943    };
944    let mut dir_opt = None;
945
946    // Canonicalize relative paths so `Path::parent()` gives a real directory.
947    // `Path::new("foo.tex").parent()` returns `Some("")` (empty string) which
948    // poisons SEARCHPATHS / SOURCEDIRECTORY: an empty-string search-path
949    // entry resolves files via cwd-name with no normalization, changing the
950    // order in which resource files (e.g. `ts1enc.def` vs `t1enc.def`) are
951    // discovered. Concrete symptom: TS1 fontmap leaks into control-sequence
952    // construction → `cn` characters become `⚮♪` → `\c@cn` undefined →
953    // 381-error cascade (paper 0709.2868). Canonicalizing matches Perl's
954    // `File::Spec->splitpath` behavior which always yields a real directory.
955    let canonical_request = if pathname::is_literaldata(&request) || pathname::is_url(&request) {
956      request.clone()
957    } else {
958      std::fs::canonicalize(&request)
959        .ok()
960        .and_then(|p| p.to_str().map(String::from))
961        .unwrap_or_else(|| request.clone())
962    };
963    let name = if pathname::is_literaldata(&request) {
964      s!("Anonymous String")
965    } else if pathname::is_url(&request) {
966      request.clone()
967    } else {
968      let path = Path::new(&canonical_request);
969      dir_opt = path.parent();
970      match path.file_stem() {
971        None => String::from("missing_name"),
972        Some(pf) => pf.to_str().unwrap().to_string(),
973      }
974    };
975    // else {
976    //   $self->withState(sub {
977    //       Fatal('missing_file', $request, undef, "Can't find $mode file $request"); }); } }
978    // };
979    // Book-scale sources legitimately expand past the arXiv-sized 400M
980    // runaway-token backstop; scale it to the input (never lowers, env
981    // override wins — see gullet::scale_token_limit_to_source).
982    let source_bytes = if pathname::is_literaldata(&request) {
983      request.len()
984    } else {
985      std::fs::metadata(&canonical_request)
986        .map(|m| m.len() as usize)
987        .unwrap_or(0)
988    };
989    gullet::scale_token_limit_to_source(source_bytes);
990    let digestion_note = s!("Digesting {}", name);
991    note_begin(&digestion_note);
992    // $self->initializestate::$mode . ".pool", @{ $$self{preload} || [] }) unless
993    // $options{noinitialize};
994    if !pathname::is_literaldata(&request) {
995      state::assign_value("SOURCEFILE", arena::pin(&request), None);
996    }
997    if let Some(dir) = dir_opt {
998      let dir_str = dir.to_str().unwrap_or(".");
999      // Perl Core.pm L195-200 unshifts the SOURCE file's directory onto
1000      // SEARCHPATHS so subsequent `\input`-style lookups resolve relative
1001      // to the main file. When `canonicalize` succeeded `dir_str` is the
1002      // absolute parent; if it failed (e.g. file not on disk yet at the
1003      // time we resolved — unusual for normal latexml CLI invocations
1004      // but possible for `literal:` etc.) `dir_str` may be empty and an
1005      // empty entry on SEARCHPATHS is useless. Fall back to CWD in that
1006      // case so paper-local files (`\input{Chapter/Abstract}` etc.) are
1007      // discoverable. Witness: arXiv:2604.09744, 2603.04457 (papers
1008      // bundling subdirectory `\subimport` chains).
1009      let resolved_dir = if dir_str.is_empty() {
1010        std::env::current_dir()
1011          .ok()
1012          .and_then(|cwd| cwd.to_str().map(String::from))
1013          .unwrap_or_else(|| ".".to_string())
1014      } else {
1015        dir_str.to_string()
1016      };
1017      state::assign_value("SOURCEDIRECTORY", arena::pin(&resolved_dir), None);
1018      // Perl Core.pm L195-200: `$state->unshiftValue(SEARCHPATHS => $dir)`.
1019      // `unshift` puts the source dir at the FRONT so it's the new "lead"
1020      // — the same lead `\lx@append@path` reads as the basis for
1021      // appended subdir paths. Pushing to BACK (`add_search_path`) left
1022      // the lead as whatever the CLI's `--path` provided (typically
1023      // `ar5iv-bindings/bindings`); `\subimport{Chapter/}{Abstract}`
1024      // then appended Chapter/ to ar5iv-bindings/bindings instead of to
1025      // the paper's directory, and `\input{Abstract}` couldn't resolve.
1026      // Witness: arXiv:2604.09744, 2603.04457.
1027      state::search_paths_push_front(resolved_dir);
1028    }
1029    //   if defined $dir && !grep { $_ eq $dir } @{ $state->lookupValue('SEARCHPATHS') };
1030    // $state->unshiftValue(GRAPHICSPATHS => $dir)
1031
1032    // if defined $dir && !grep { $_ eq $dir } @{ $state->lookupValue('GRAPHICSPATHS') };
1033
1034    let name_copy = name.clone();
1035    state::install_definition(
1036      Stored::Expandable(Rc::new(Expandable {
1037        cs: T_CS!("\\jobname"),
1038        paramlist: None,
1039        expansion: Tokens::new(Explode!(name_copy)).into(),
1040        ..Expandable::default()
1041      })),
1042      None,
1043    );
1044
1045    // Reverse order, since last opened is first read!
1046    // (Perl: Core.pm L154-157 in `digestFile`.)
1047    if let Some(postamble) = postamble {
1048      self.load_postamble(postamble);
1049    }
1050    input_content(&request, InputOptions::default())?;
1051    if let Some(preamble) = preamble {
1052      self.load_preamble(preamble);
1053    }
1054
1055    // Now for the Hacky part for BibTeX!!!
1056    // Perl `Core.pm` L160-162: drain the .bib mouth via the Pre::BibTeX
1057    // parser, register each entry in the bibtex.rs thread-local
1058    // registry, and push back a `literal:` wrapper that produces a
1059    // `\begin{bibtex@bibliography}...\end{bibtex@bibliography}` block
1060    // for the digester to process.
1061    if matches!(mode, Some(DigestionMode::BibTeX)) {
1062      use latexml_engine::pre_bibtex::PreBibTeX;
1063      let mut bib = PreBibTeX::new_from_gullet(&name);
1064      match bib.to_tex() {
1065        Ok(tex) => {
1066          input_content(&s!("literal:{tex}"), InputOptions::default())?;
1067        },
1068        Err(parse_err) => {
1069          Error!(
1070            "bibtex",
1071            "parse_failed",
1072            s!("Failed to parse BibTeX file {}: {:?}", name, parse_err)
1073          );
1074        },
1075      }
1076    }
1077
1078    Ok(digestion_note)
1079  }
1080
1081  fn convert_file(&mut self, filepath: String) -> Result<Document> {
1082    match self.digest_file(filepath, DigestionOptions::default()) {
1083      Err(e) => Err(e),
1084      Ok(digested) => self.convert_document(digested),
1085    }
1086  }
1087
1088  fn convert_streaming(
1089    &mut self,
1090    request: String,
1091    preamble: Option<String>,
1092    postamble: Option<String>,
1093    mode: Option<DigestionMode>,
1094    budget: usize,
1095  ) -> Result<Document> {
1096    use latexml_core::sxml::{FragmentIndex, SegmentStore};
1097    let digestion_note = self.digest_setup(request, preamble, postamble, mode)?;
1098    let mut document = build_document_head(&self.preload)?;
1099    latexml_core::document::reset_spilled_segment_count();
1100    // The spill area lives beside the source when that directory is WRITABLE
1101    // (same volume as the output in the by-far-common layout, so the
1102    // disk-headroom check measures the filesystem the spill actually
1103    // consumes), else the system temp dir. The writability test is not
1104    // hypothetical: auto-activation fires on exactly the large documents that
1105    // get processed from read-only trees — an arXiv bulk mount, a CI
1106    // checkout — and creating the directory unconditionally failed the whole
1107    // conversion there. Literal input has no source directory at all.
1108    let spill_anchor = match state::lookup_value("SOURCEDIRECTORY") {
1109      Some(Stored::String(dir)) => {
1110        let dir = arena::with(dir, |s: &str| std::path::PathBuf::from(s));
1111        if dir_is_writable(&dir) {
1112          dir
1113        } else {
1114          emit_info(
1115            "streaming",
1116            "progress",
1117            &format!(
1118              "streaming: {} is not writable; spilling to {} instead",
1119              dir.display(),
1120              std::env::temp_dir().display()
1121            ),
1122          );
1123          std::env::temp_dir()
1124        }
1125      },
1126      _ => std::env::temp_dir(),
1127    };
1128    let store = SegmentStore::create(&spill_anchor)?;
1129    // Disk-headroom gate (user requirement 2026-07-29): the spill needs
1130    // roughly the core-XML size on disk — measured ~12x the source bytes on
1131    // math-heavy content — and a silent mid-conversion ENOSPC would be the
1132    // OOM-kill failure mode all over again. Verify up front and raise a
1133    // Fatal that NAMES the shortfall and the requirement, so the user can
1134    // free space (or point --dest at a roomier volume) with numbers in hand.
1135    {
1136      use latexml_core::common::error::{ErrorCategory, ErrorTarget};
1137      const SPILL_BYTES_PER_SOURCE_BYTE: u64 = 16; // ~12x measured + slack
1138      let src_bytes = match state::lookup_value("SOURCEFILE") {
1139        Some(Stored::String(f)) => {
1140          arena::with(f, |f| std::fs::metadata(f).map(|m| m.len()).unwrap_or(0))
1141        },
1142        _ => 0,
1143      };
1144      let need = src_bytes.saturating_mul(SPILL_BYTES_PER_SOURCE_BYTE);
1145      if let Some(avail) = latexml_core::watchdog::available_disk_bytes(store.dir())
1146        && avail < need
1147      {
1148        stomach::set_fragment_yield_budget(None);
1149        return Err(Error {
1150          target:   ErrorTarget::Timeout,
1151          category: ErrorCategory::MemoryBudget,
1152          message:  s!(
1153            "streaming spill needs ~{} MB free under {} but only {} MB is available — free disk space there, or convert with a destination on a roomier volume",
1154            need / (1024 * 1024),
1155            store.dir().display(),
1156            avail / (1024 * 1024)
1157          ),
1158        });
1159      }
1160    }
1161    document.set_spill_store(store);
1162    document.set_defer_root_after_open(true);
1163    // Pass 1 serializes placeholders literally (nested spills stay nested;
1164    // the final assembly resolves them recursively — see the field docs).
1165    document.literal_placeholders = true;
1166    // Spill segments are an intermediate that pass 2 re-serializes; the
1167    // indentation pass 1 used to emit was generated, written, read back,
1168    // parsed into ~40M text nodes and then deleted again. See `spill_flat`.
1169    document.spill_flat = true;
1170    let mut index = FragmentIndex::default();
1171    stomach::set_fragment_yield_budget(Some(budget));
1172    // Soft-RSS yield: fire regardless of box count once RSS crosses the
1173    // watermark — the box budget assumes a per-box footprint, and math-dense
1174    // content blows past it (witness: fuse at 18.8 GB with the box budget
1175    // untouched). `spill_watermark_bytes` owns the policy, including the
1176    // `--max-memory=0` case where there is no fuse to divide.
1177    if let Some(watermark) = stomach::spill_watermark_bytes() {
1178      stomach::set_fragment_yield_rss_soft_kb(Some(watermark / 1024));
1179    }
1180
1181    // Phase clock. A streamed run's cost splits across pass 1 (digest →
1182    // absorb → spill), pass 2 (per-segment tail) and assembly, and the log
1183    // carried NO timing at all — the 131 MB witness's 70-minute wall could
1184    // only be attributed by extrapolating between two segment checkpoints, or
1185    // by running a paired control binary for another 70 minutes. One elapsed
1186    // figure per phase seam makes every run self-attributing.
1187    let phase_clock = std::time::Instant::now();
1188    // Pass 1: interleaved digest → absorb → spill, at legal seams only.
1189    let mut fatal_stop = false;
1190    loop {
1191      let mut boxes = Vec::new();
1192      let step = {
1193        // Streaming had NO telemetry phases at all — every `phase()` guard in
1194        // the tree is on the eager path — so `TELEMETRY.md`'s "sum of phase
1195        // wall ≈ total wall, median ≥ 0.92" was silently unmet for every
1196        // streamed conversion. Reuse the eager phases rather than adding
1197        // streaming-specific ones: pass 1 IS digest + build, interleaved.
1198        let _g = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::Digest);
1199        digest_step_guarded(&mut boxes)
1200      };
1201      let yielded = stomach::take_fragment_yielded();
1202      let mut stopped = match step {
1203        Ok(keep_going) => !keep_going,
1204        Err(e) => {
1205          // Under EAGER digestion a resource fatal (the RSS fuse) is
1206          // deliberately not recovered — continuing would allocate straight
1207          // into an OOM. Under STREAMING the calculus inverts: stopping
1208          // digestion here FREES the box list, the spill below releases the
1209          // DOM, and the pipeline finishes on already-spilled content — so
1210          // recovery is not just safe, it is the feature this mode exists
1211          // for. The Fatal contract still holds: announce + latch the
1212          // verdict, keep the partial document (user policy 2026-07-28).
1213          e.log_fatal();
1214          emit_warn(
1215            "internal",
1216            "core_interface",
1217            &format!(
1218              "convert_streaming: digestion stopped by a resource fatal ({:?}/{:?}) — keeping the document built so far",
1219              e.target, e.category
1220            ),
1221          );
1222          // Recover what the interrupted step had digested. Unlike the eager
1223          // Stomach-target salvage, drop_innermost is FALSE: there is no
1224          // runaway construct to excise here — the innermost level IS the
1225          // healthy top-level accumulation, and the budget simply ran out.
1226          // Safe because everything salvaged is absorbed, spilled and freed
1227          // immediately below.
1228          boxes.extend(stomach::salvage_pending_box_lists(false));
1229          fatal_stop = true;
1230          true
1231        },
1232      };
1233      if !boxes.is_empty() {
1234        let digested = Digested::from(List::new(boxes));
1235        let _g_build = latexml_core::telemetry::phase(latexml_core::telemetry::Phase::Build);
1236        if let Err(e) = document.absorb(&digested, None) {
1237          // Same Fatal contract as the eager Build: announce, latch, keep the
1238          // partial document (recovery is a FEATURE of Fatal).
1239          e.log_fatal();
1240          emit_warn(
1241            "internal",
1242            "core_interface",
1243            &format!(
1244              "convert_streaming: build stopped early ({:?}/{:?}) — keeping the document built so far",
1245              e.target, e.category
1246            ),
1247          );
1248          fatal_stop = true;
1249          stopped = true;
1250        }
1251      }
1252      // Perl inserts resources directly once a document exists; the eager
1253      // path's root-hook drain is deferred here, so fold fresh arrivals in
1254      // per fragment — mid-digestion consumers (the frontmatter fallback's
1255      // resource[last()] anchor) depend on them being placed.
1256      document.process_pending_resources_at_top()?;
1257      let finishing = stopped || (!yielded && !gullet::has_more_input());
1258      // Spill policy at the end: a conversion that NEVER yielded fits in RAM
1259      // whole — spilling it would buy no headroom and cost a full
1260      // serialize→reparse→reserialize cycle in pass 2 (measured +38% wall
1261      // time at a roomy cap). But once yields happened, the ceiling is real
1262      // and the FINAL fragment must spill like every other one: retaining it
1263      // hands the eager-style spine tail (rewrites, whole-doc math parse,
1264      // finalize) everything the last fragment held — the witness died in
1265      // exactly that tail at 24.5 GB after a perfectly bounded pass 1.
1266      let bounded_mode = stomach::fragment_yield_count() > 0;
1267      if !finishing || bounded_mode {
1268        document.spill_closed_subtrees(&mut index)?;
1269        // Self-healing: entries for nodes that build-time discard paths
1270        // detached without purging pin whole Digested box trees (see
1271        // sweep_stale_node_boxes). The threshold keeps the sweep rare and
1272        // the map bounded; the post-spill spine mark is cheap.
1273        if document.node_boxes.len() > 1_000_000 {
1274          document.sweep_stale_node_boxes();
1275        }
1276        // Rate-limited: a book-scale run yields MILLIONS of fragments, and
1277        // every log line lands in the in-RAM LOG_BUFFER — per-fragment
1278        // telemetry alone wrote a 1.37 GB log on the 131 MB witness,
1279        // feeding the very creep pass 1 exists to avoid.
1280        let fragments = stomach::fragment_yield_count();
1281        // Hand freed spill memory back to the OS. The spilled DOM lives on
1282        // GLIBC's heap (libxml2 allocates via libc malloc, NOT the Rust
1283        // global allocator), and glibc keeps freed mid-heap pages mapped —
1284        // measured on the 131 MB witness: every probed Rust collection flat,
1285        // C live-heap 4.45 GB peak (heaptrack), yet RSS crept 22→36 GB into
1286        // the fuse. `malloc_trim(0)` madvises free pages away (glibc ≥2.26
1287        // releases mid-heap pages too, not just the top). Rate-limited: a
1288        // trim walks the heap, and a book-scale run yields millions of
1289        // fragments.
1290        #[cfg(target_os = "linux")]
1291        if fragments.is_multiple_of(4096) {
1292          unsafe {
1293            libc::malloc_trim(0);
1294          }
1295        }
1296        // The Rust-side analogue: mimalloc (the global allocator) retains
1297        // freed pages; a forced collect purges them back to the OS. Gated
1298        // off under dhat-heap, whose tracking allocator replaces mimalloc.
1299        #[cfg(not(feature = "dhat-heap"))]
1300        if fragments.is_multiple_of(4096) {
1301          unsafe {
1302            libmimalloc_sys::mi_collect(true);
1303          }
1304        }
1305        if fragments.is_power_of_two() || fragments.is_multiple_of(65536) {
1306          let (index_ids, index_labels, _) = index.sizes();
1307          // C-heap split (glibc only manages libxml2's allocations — Rust
1308          // goes through mimalloc): uordblks = live C bytes, fordblks =
1309          // freed-but-retained. Together with RSS this separates C live
1310          // growth / C fragmentation / Rust growth.
1311          #[cfg(target_os = "linux")]
1312          let (c_live_mb, c_free_mb) = {
1313            let mi = unsafe { libc::mallinfo2() };
1314            (mi.uordblks / (1024 * 1024), mi.fordblks / (1024 * 1024))
1315          };
1316          #[cfg(not(target_os = "linux"))]
1317          let (c_live_mb, c_free_mb) = (0usize, 0usize);
1318          let spine_children = document
1319            .get_document()
1320            .get_root_element()
1321            .map(|r| r.get_child_nodes().len())
1322            .unwrap_or(0);
1323          let (mouths, comments) = gullet::queue_sizes();
1324          emit_info(
1325            "streaming",
1326            "progress",
1327            &format!(
1328              "streaming: undo {}; mouths {}; comments {}; node_boxes {}",
1329              state::undo_depth(),
1330              mouths,
1331              comments,
1332              document.node_boxes.len(),
1333            ),
1334          );
1335          emit_info(
1336            "streaming",
1337            "progress",
1338            &format!(
1339              "streaming: fragment {} absorbed; {} segment(s) staged to disk; RSS ~{} MB; C-live {} MB; C-free {} MB; root-children {}; idstore {}; index {}+{}; arena {}; fonts {}; pending {}",
1340              fragments,
1341              latexml_core::document::spilled_segment_count(),
1342              stomach::last_sampled_rss_kb() / 1024,
1343              c_live_mb,
1344              c_free_mb,
1345              spine_children,
1346              document.idstore.len(),
1347              index_ids,
1348              index_labels,
1349              arena::len(),
1350              document.node_fonts.len(),
1351              document.pending.len(),
1352            ),
1353          );
1354        }
1355      }
1356      if finishing {
1357        break;
1358      }
1359    }
1360    stomach::set_fragment_yield_budget(None);
1361    stomach::set_fragment_yield_rss_soft_kb(None);
1362    gullet::flush();
1363    note_end(&digestion_note);
1364    let pass1_elapsed = phase_clock.elapsed();
1365    emit_info(
1366      "streaming",
1367      "progress",
1368      &format!(
1369        "streaming: PASS 1 done in {:.1?} — {} yield(s), {} segment(s) staged to disk, RSS ~{} MB",
1370        pass1_elapsed,
1371        stomach::fragment_yield_count(),
1372        latexml_core::document::spilled_segment_count(),
1373        latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024,
1374      ),
1375    );
1376
1377    // The ROOT's after-open hooks were DEFERRED during pass 1
1378    // (`set_defer_root_after_open`): eager semantics guarantee every hook
1379    // runs with digestion complete, and firing them at fragment 1 was not
1380    // merely incomplete but harmful — the frontmatter hook consumed EMPTY
1381    // frontmatter state and marked it done (losing the abstract; sweep
1382    // witness tests/structure/abstract.tex), and the root-classes hook read
1383    // a `DOCUMENT_CLASSES` mapping `\maketitle` had not populated yet
1384    // (dropping `ltx_authors_1line`; gate witness). Dispatch exactly once,
1385    // now, with digestion finished — the eager timing.
1386    document.set_defer_root_after_open(false);
1387    // Late-arrived frontmatter first (canonically positioned after what the
1388    // mid-digestion insertion already placed), then the root's deferred late
1389    // hooks (which perform the whole insertion for documents where nothing
1390    // ran early).
1391    latexml_engine::base_utilities::insert_late_frontmatter(&mut document)?;
1392    document.dispatch_deferred_root_hooks()?;
1393    if fatal_stop {
1394      // CHEAP partial (Fatal contract under real memory pressure): every
1395      // further phase allocates while we are AT the ceiling — the first
1396      // implementation marched from the 18.8 GB cooperative fuse straight
1397      // into the 24 GB hard watchdog (SIGKILL) doing pass 2. Skip pass 2 and
1398      // the spine tail: spilled segments splice in their raw pre-finalize
1399      // form — well-formed XML that still carries `_`-bookkeeping attributes,
1400      // which a salvaged partial is allowed to. The verdict is already
1401      // latched Fatal.
1402      emit_warn(
1403        "internal",
1404        "core_interface",
1405        "convert_streaming: fatal stop — emitting the cheap partial (pass 2 and the finalize tail skipped to avoid allocating at the memory ceiling)",
1406      );
1407      // The partial's serialization must still RESOLVE placeholders (raw
1408      // segments splice recursively) — literal mode was for pass 1 only.
1409      document.literal_placeholders = false;
1410      // Flat serialization was for the spill INTERMEDIATE only — the output
1411      // keeps its formatting.
1412      document.spill_flat = false;
1413      return Ok(document);
1414    }
1415    // RDFa prefixes used inside spilled content: the finalize scan can no
1416    // longer see them, so feed the spill-time record in.
1417    document.add_extra_rdfa_prefixes(index.rdfa_prefixes().map(|(p, _)| p));
1418
1419    // The rule set must be complete before ANY rewrites run: the source's
1420    // `.latexml` file loads only now (as in the eager path), and fragments
1421    // must see it too — which is exactly why pass 1 applies no rewrites.
1422    load_source_latexml_rules();
1423    // Pass 2 mutates segments while fragment documents live independently, so
1424    // it borrows the store OUT of the spine and hands it back for assembly.
1425    let mut store = document
1426      .take_spill_store()
1427      .expect("attached above; nothing detaches it during pass 1");
1428    let node_fonts = document.node_fonts.clone();
1429    // Root-level id counters run document-wide (see streaming_pass2's doc):
1430    // seed pass 2 from the spine root's pass-1 state, and hand the final
1431    // state back to the root so the spine's own tail continues the sequence.
1432    let mut counters: Vec<(String, String)> = document
1433      .get_document()
1434      .get_root_element()
1435      .map(|root| {
1436        root
1437          .get_attributes()
1438          .into_iter()
1439          .filter(|(k, _)| k.starts_with("_ID_counter"))
1440          .collect()
1441      })
1442      .unwrap_or_default();
1443    let pass2_start = phase_clock.elapsed();
1444    streaming_pass2(&mut store, &index, &node_fonts, &mut counters)?;
1445    emit_info(
1446      "streaming",
1447      "progress",
1448      &format!(
1449        "streaming: PASS 2 done in {:.1?} ({:.1?} cumulative) — {} segment(s), RSS ~{} MB",
1450        phase_clock.elapsed().saturating_sub(pass2_start),
1451        phase_clock.elapsed(),
1452        latexml_core::document::spilled_segment_count(),
1453        latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024,
1454      ),
1455    );
1456    if let Some(mut root) = document.get_document().get_root_element() {
1457      for (key, value) in &counters {
1458        let _ = root.set_attribute(key, value);
1459      }
1460    }
1461    // The spine's own rewrite phase must be strict too: its sections are
1462    // spilled placeholders, so a scope that "isn't here" is in a fragment.
1463    document.scoped_rules_strict = true;
1464    // The spine gets the normal whole-document tail; spilled content is
1465    // invisible to it (placeholders), so root-level passes act on the live
1466    // root exactly as in the eager path.
1467    finish_document(&mut document)?;
1468    // From here serialization splices the processed segments at their
1469    // placeholders (recursively — pass 1 kept nested spills nested).
1470    document.literal_placeholders = false;
1471    // Flat serialization was for the spill INTERMEDIATE only; the spine and
1472    // the spliced segment text must carry the normal output formatting.
1473    document.spill_flat = false;
1474    document.set_spill_store(store);
1475    Ok(document)
1476  }
1477
1478  /// Restriction: convert_document runs on a single thread, and should never try branching out.
1479  fn convert_document(&mut self, digested: Digested) -> Result<Document> {
1480    note_begin("Building");
1481    let mut document = build_document_head(&self.preload)?;
1482    Debug!("Doc absorb: {:?}", digested);
1483
1484    // A Build that runs out of budget KEEPS what it has already built. The
1485    // guard tick inside `absorb` (see `document.rs`) can now raise a resource
1486    // Fatal mid-build; propagating it with `?` would discard a document that is
1487    // structurally sound up to the cut and hand the user a 39-byte stub — the
1488    // same loss `digest_internal` already salvages against on the digestion
1489    // side. Recovery is a FEATURE of Fatal here (user policy 2026-07-28):
1490    // announce it, keep the partial document, finish the pipeline gracefully.
1491    // `log_fatal` both emits the `Fatal:` line and latches the status, so the
1492    // run still reports as failed and never masquerades as clean.
1493    if let Err(e) = document.absorb(&digested, None) {
1494      e.log_fatal();
1495      emit_warn(
1496        "internal",
1497        "core_interface",
1498        &format!(
1499          "convert_document: build stopped early ({:?}/{:?}) — keeping the \
1500         document built so far",
1501          e.target, e.category
1502        ),
1503      );
1504    }
1505    note_end("Building");
1506
1507    load_source_latexml_rules();
1508    finish_document(&mut document)?;
1509    Ok(document)
1510  }
1511
1512  fn digest_internal(&mut self) -> Result<Digested> {
1513    let mut boxes = Vec::new();
1514    while gullet::has_more_input() {
1515      // Perl finishDigestion L219-220: loop consuming input even after errors.
1516      if !digest_step_guarded(&mut boxes)? {
1517        break;
1518      }
1519    }
1520    gullet::flush();
1521    Ok(Digested::from(List::new(boxes)))
1522  }
1523
1524  //%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
1525  // Mid-level API.
1526
1527  // options are currently being evolved to accomodate the Daemon:
1528  //    mode  : the processing mode, ie the pool to preload: TeX or BibTeX
1529  //    noinitialize : if defined, it does not initialize State.
1530  //    preamble = names a tex file (or standard_preamble.tex)
1531  //    postamble = names a tex file (or standard_postamble.tex)
1532
1533  /// Restriction: `digest_file` runs on a single thread, and should never try branching out.
1534  fn digest_file(&mut self, mut request: String, options: DigestionOptions) -> Result<Digested> {
1535    let mut dir = String::new();
1536    let name;
1537    // let mut ext = String::new();
1538    let mode = match options.mode {
1539      None => DigestionMode::TeX,
1540      Some(m) => m,
1541    };
1542
1543    if pathname::is_literaldata(&request) {
1544      // ext = mode.extension();
1545      name = s!("Anonymous String");
1546    } else if pathname::is_url(&request) {
1547      // ext = mode.extension();
1548      name = request.clone();
1549    } else {
1550      let ext_str = s!(".{}", mode.extension());
1551      let request_base = if request.ends_with(&ext_str) {
1552        request[0..request.len() - ext_str.len()].to_string()
1553      } else {
1554        request
1555      };
1556
1557      if let Some(pathname) = pathname::find(&request_base, PathnameFindOptions {
1558        extensions: Some(vec![mode.extension(), String::new()]),
1559        ..PathnameFindOptions::default()
1560      }) {
1561        request = pathname;
1562        dir = pathname::directory(&request);
1563        name = pathname::file_stem(&request);
1564        // Perl Core.pm L195-200 unshifts the SOURCE file's directory onto
1565        // SEARCHPATHS so subsequent `\input`-style lookups resolve relative
1566        // to the main file. When the user invokes with a bare filename
1567        // (e.g. `latexml_oxide neurips_2025.tex` from the paper's cwd),
1568        // `pathname::find` returns the relative match and `pathname::
1569        // directory` returns an empty string — which then gets pushed
1570        // onto SEARCHPATHS as a useless entry. Resolve to CWD in that
1571        // case so the paper-local Chapter/ etc. are reachable.
1572        if dir.is_empty()
1573          && let Ok(cwd) = std::env::current_dir()
1574        {
1575          dir = cwd.to_string_lossy().to_string();
1576        }
1577      // ext = pathname::extension(&request);
1578      } else {
1579        let message = s!("Can't find {} file {} ", mode, request_base);
1580        fatal!(Core, MissingFile, message);
1581      }
1582    }
1583    note_begin(&s!("Digesting {} {}", mode, name));
1584    let main_pool = s!("{}.pool", mode);
1585    let noinitialize = options.noinitialize.unwrap_or(false);
1586    if !noinitialize {
1587      let mut preloads = vec![main_pool];
1588      preloads.extend(self.preload.clone());
1589      self.initialize_singletons(preloads)?;
1590    }
1591    {
1592      let source_file = if pathname::is_literaldata(&request) {
1593        None
1594      } else {
1595        Some(request.as_str())
1596      };
1597      establish_source_context(source_file, &name, &dir);
1598    }
1599
1600    // Reverse order, since last opened is first read!
1601    if let Some(postamble) = options.postamble {
1602      self.load_postamble(postamble);
1603    }
1604
1605    {
1606      // Make sure the stomach trick is used very *tightly*, always with a surrounding scope.
1607      input_content(&request, InputOptions::default())?;
1608    }
1609
1610    if let Some(preamble) = options.preamble {
1611      self.load_preamble(preamble);
1612    }
1613
1614    // Now for the Hacky part for BibTeX!!!
1615    // Perl `Core.pm` L160-162: drain the mouth(s) just opened on the
1616    // .bib file, run the low-level parser to build a registry of
1617    // BibEntry objects, then push a literal wrapper TeX block that
1618    // the LaTeX-side digester reads back as
1619    //   \begin{bibtex@bibliography}
1620    //     \ProcessBibTeXEntry{<key1>}
1621    //     ...
1622    //   \end{bibtex@bibliography}
1623    if matches!(mode, DigestionMode::BibTeX) {
1624      use latexml_engine::pre_bibtex::PreBibTeX;
1625      let mut bib = PreBibTeX::new_from_gullet(&name);
1626      match bib.to_tex() {
1627        Ok(tex) => {
1628          input_content(&s!("literal:{tex}"), InputOptions::default())?;
1629        },
1630        Err(parse_err) => {
1631          Error!(
1632            "bibtex",
1633            "parse_failed",
1634            s!("Failed to parse BibTeX file {}: {:?}", name, parse_err)
1635          );
1636        },
1637      }
1638    }
1639
1640    let list = self.digest_internal()?;
1641    note_end(&s!("Digesting {} {}", mode, name));
1642    Ok(list)
1643  }
1644}
1645
1646/// Establish the document-global *source context* for a **top-level** document
1647/// load — `SOURCEFILE`, `SOURCEDIRECTORY`, the front of `SEARCHPATHS`,
1648/// `GRAPHICSPATHS`, and `\jobname` (Perl Core.pm L195-200). Shared by
1649/// [`DigestionAPI::digest_file`] (content read from disk) and
1650/// [`crate::converter::Converter::digest_content_with_provenance`] (content
1651/// supplied in memory) so the two cannot drift.
1652///
1653/// `source_file` is the value for `SOURCEFILE` — the source's path/identity —
1654/// or `None` for an anonymous/literal source. `jobname` is the bare job name
1655/// (file stem). `dir` is the source directory (may be empty, e.g. a literal).
1656///
1657/// This is for the *main* document only; a nested `\input`/continuation must
1658/// not reset these document-global values.
1659pub(crate) fn establish_source_context(source_file: Option<&str>, jobname: &str, dir: &str) {
1660  if let Some(sf) = source_file {
1661    state::assign_value("SOURCEFILE", sf.to_string(), None);
1662  }
1663  if !dir.is_empty() {
1664    state::assign_value("SOURCEDIRECTORY", dir.to_string(), None);
1665  }
1666  state::search_paths_push_front(dir.to_string());
1667  // Perl Core.pm L200: unshift GRAPHICSPATHS => $dir unless already present.
1668  if !state::graphics_paths_contains(dir) {
1669    state::graphics_paths_push_front(dir.to_string());
1670  }
1671  state::install_definition(
1672    Stored::Expandable(Rc::new(Expandable {
1673      cs: T_CS!("\\jobname"),
1674      paramlist: None,
1675      expansion: Tokens::new(Explode!(jobname)).into(),
1676      ..Expandable::default()
1677    })),
1678    None,
1679  );
1680}
1681
1682/// Load a `.latexml` file alongside a `.tex` source file.
1683/// Parses `DefMathRewrite(...)` calls and registers them as rewrite rules.
1684/// Perl loads these automatically; this provides the equivalent for Rust tests.
1685///
1686/// Supported patterns:
1687///   - Single character: `match => 'a'` -> XPath on XMTok text content
1688///   - Complex patterns (e.g. `\hat{f}`, `f_D`, `f_\WildCard`): skipped
1689///   - `scope => 'label:...'`: scoped rewrites via label lookup
1690///   - `attributes => { role => 'FUNCTION' }`: sets role (and optionally name/meaning)
1691fn load_latexml_file(path: &str) -> Result<()> {
1692  use latexml_core::rewrite::{RewriteClause, RewriteOperator, RewritePattern};
1693
1694  let content = match std::fs::read_to_string(path) {
1695    Ok(c) => c,
1696    Err(_) => return Ok(()), // File doesn't exist or can't be read
1697  };
1698
1699  for cap in DEF_MATH_REWRITE_RE.captures_iter(&content) {
1700    let body = &cap[1];
1701
1702    // Extract match pattern
1703    let match_str = match MATCH_RE.captures(body) {
1704      Some(m) => m[1].to_string(),
1705      None => continue, // No match clause, skip
1706    };
1707
1708    // Build attributes map from the attributes => { ... } section
1709    let mut attrs = HashMap::default();
1710    if let Some(role_cap) = ROLE_RE.captures(body) {
1711      attrs.insert("role".to_string(), role_cap[1].to_string());
1712    }
1713    if let Some(name_cap) = NAME_ATTR_RE.captures(body) {
1714      attrs.insert("name".to_string(), name_cap[1].to_string());
1715    }
1716    if let Some(meaning_cap) = MEANING_RE.captures(body) {
1717      attrs.insert("meaning".to_string(), meaning_cap[1].to_string());
1718    }
1719    if attrs.is_empty() {
1720      continue; // No attributes to set
1721    }
1722
1723    // Check for optional scope
1724    let scope_str = SCOPE_RE.captures(body).map(|s| s[1].to_string());
1725
1726    // Use compile_declare_pattern for all patterns (simple + complex).
1727    // The .latexml match strings use the same format as \lxDeclare body_text:
1728    //   'f' (simple), 'f_D' (literal subscript), 'f_\WildCard' (wildcard),
1729    //   '\hat{f}' (accent), "x^{\prime}" (prime).
1730    let pat = latexml_core::rewrite::declare::compile_declare_pattern(&match_str);
1731    if pat.xpath.is_empty() {
1732      continue; // Unrecognized pattern, skip
1733    }
1734
1735    // For math mode, append visibility check to XPath
1736    let xpath = format!("{}[@_pvis and @_cvis]", pat.xpath);
1737
1738    // Determine select_count based on pattern type
1739    let select_count = pat.select_count().or(Some(1usize));
1740
1741    // Build the rewrite rule
1742    let mut clauses = Vec::new();
1743
1744    // Add scope clause if present
1745    if let Some(ref scope) = scope_str {
1746      clauses.push(RewriteClause::new_uncompiled(
1747        RewriteOperator::Scope,
1748        RewritePattern::String(scope.clone()),
1749      ));
1750    }
1751
1752    // Add match clause (pre-compiled as XPath string)
1753    clauses.push(RewriteClause::new_uncompiled(
1754      RewriteOperator::Match,
1755      RewritePattern::String(xpath),
1756    ));
1757
1758    // Add attributes clause
1759    clauses.push(RewriteClause::new_compiled(
1760      RewriteOperator::Attributes,
1761      RewritePattern::String(String::new()),
1762    ));
1763
1764    let rewrite = Rewrite {
1765      options: RewriteOptions {
1766        attributes_map: Some(attrs),
1767        is_math: true,
1768        select_count,
1769        wildcard_paths: pat.wildcard_paths.clone(),
1770        declare_filter: Some(pat),
1771        ..RewriteOptions::default()
1772      },
1773      clauses,
1774    };
1775
1776    state::push_value("DOCUMENT_REWRITE_RULES", rewrite)?;
1777  }
1778
1779  Ok(())
1780}
1781
1782/// Apply \lxDeclare declarations to the document.
1783/// Simple fast-path: matches single-token patterns in XMTok elements
1784/// and sets role/name/meaning attributes.
1785fn apply_lx_declarations(document: &mut Document, ambient_section: Option<&str>) {
1786  let decls_str = match state::lookup_value("LATEXML_DECLARATIONS") {
1787    Some(Stored::String(s)) => arena::with(s, |r| r.to_string()),
1788    _ => return,
1789  };
1790  if decls_str.is_empty() {
1791    return;
1792  }
1793
1794  // Parse declarations:
1795  // "token_text\trole\tname\tmeaning\tdecl_id\tmatch_font\tscope_prefix".
1796  // match_font (font_attribute_string of the digested pattern, e.g.
1797  // "italic"/"bold") makes matching font-aware: a plain italic `$x$` declaration
1798  // must not annotate a bold `\mathbf{x}` — different fonts denote different
1799  // meanings; empty when the pattern carried no distinguishing font.
1800  // scope_prefix carries the section gate for scope=section declarations
1801  // (INCLUDING untagged ones, which have no decl_id to infer it from).
1802  let declarations: Vec<(&str, &str, &str, &str, &str, &str, &str)> = decls_str
1803    .lines()
1804    .filter_map(|line| {
1805      let parts: Vec<&str> = line.splitn(7, '\t').collect();
1806      if parts.len() >= 4 {
1807        Some((
1808          parts[0],
1809          parts[1],
1810          parts[2],
1811          parts[3],
1812          *parts.get(4).unwrap_or(&""),
1813          *parts.get(5).unwrap_or(&""),
1814          *parts.get(6).unwrap_or(&""),
1815        ))
1816      } else {
1817        None
1818      }
1819    })
1820    .collect();
1821
1822  if declarations.is_empty() {
1823    return;
1824  }
1825
1826  // Find all XMTok elements in the document and apply matching declarations.
1827  // Skip tokens already marked by the rewrite system (_matched) — these were
1828  // handled by subscript/prime/wildcard patterns which take precedence.
1829  let xmtoks = document.findnodes("descendant-or-self::ltx:XMTok", None);
1830  for mut tok in xmtoks {
1831    if tok.has_attribute("_matched") {
1832      continue;
1833    }
1834    let content = tok.get_content();
1835    let tok_name = tok.get_attribute("name").unwrap_or_default();
1836    // Find the section scope of this token (ancestor section's xml:id)
1837    let tok_scope = {
1838      let mut scope = String::new();
1839      let mut cur = tok.get_parent();
1840      while let Some(p) = cur {
1841        if p.get_name() == "section" {
1842          scope = p
1843            .get_property("id")
1844            .or_else(|| p.get_attribute("xml:id"))
1845            .unwrap_or_default();
1846          break;
1847        }
1848        cur = p.get_parent();
1849      }
1850      if scope.is_empty() {
1851        // Streaming fragment: the enclosing section lives on the spine — its
1852        // id was recorded at spill time (SegmentMeta::section_id).
1853        scope = ambient_section.unwrap_or_default().to_string();
1854      }
1855      scope
1856    };
1857
1858    for &(pattern, role, name, meaning, decl_id, match_font, scope_prefix) in &declarations {
1859      // Match by content text, or by XMTok name attribute (for CS patterns like \circ)
1860      let matches = content == pattern
1861        || (!tok_name.is_empty() && pattern.starts_with('\\') && pattern[1..] == tok_name);
1862      if matches {
1863        // Font-class check (mirrors declare_node_matches in the rewrite path):
1864        // discriminate only on the meaningful font *classes* — bold vs not,
1865        // caligraphic/typewriter family — NOT on an exact font-string match.
1866        // Exact equality is wrong here because the declaration's digested frame
1867        // font (e.g. "italic") differs from an upright operator token's font
1868        // even when they should match (e.g. `$*$` → the ∗ COMPOSEOP token).
1869        // A plain/italic declaration rejects bold/caligraphic/typewriter tokens
1870        // (so italic `$x$` skips bold `\mathbf{x}`); a declaration that is
1871        // itself bold/caligraphic/typewriter requires the token to share it.
1872        {
1873          let tf = document.get_node_font(&tok);
1874          let tok_bold = tf
1875            .get_series()
1876            .map(|s| s.as_ref() == "bold")
1877            .unwrap_or(false);
1878          let tok_fam = tf.get_family();
1879          let tok_cal = tok_fam
1880            .as_ref()
1881            .map(|f| f.as_ref() == "caligraphic")
1882            .unwrap_or(false);
1883          let tok_tt = tok_fam
1884            .as_ref()
1885            .map(|f| f.as_ref() == "typewriter")
1886            .unwrap_or(false);
1887          let decl_bold = match_font.contains("bold");
1888          let decl_cal = match_font.contains("caligraphic");
1889          let decl_tt = match_font.contains("typewriter");
1890          if tok_bold != decl_bold || tok_cal != decl_cal || tok_tt != decl_tt {
1891            continue;
1892          }
1893        }
1894        // Scope gate: the explicit scope_prefix (covers UNTAGGED
1895        // scope=section declarations), falling back to the decl_id's section
1896        // prefix for older/tagged lines.
1897        let gate = if !scope_prefix.is_empty() {
1898          scope_prefix
1899        } else {
1900          decl_id.split('.').next().unwrap_or("")
1901        };
1902        if (!decl_id.is_empty() || !scope_prefix.is_empty())
1903          && !gate.is_empty()
1904          && !tok_scope.is_empty()
1905          && tok_scope != gate
1906        {
1907          continue; // Wrong section — skip this declaration
1908        }
1909        if !role.is_empty() {
1910          let _ = tok.set_attribute("role", role);
1911        }
1912        if !name.is_empty() {
1913          let _ = tok.set_attribute("name", name);
1914        }
1915        if !meaning.is_empty() {
1916          let _ = tok.set_attribute("meaning", meaning);
1917        }
1918        if !decl_id.is_empty() {
1919          let _ = tok.set_attribute("decl_id", decl_id);
1920        }
1921        break; // First matching declaration wins
1922      }
1923    }
1924  }
1925}
1926
1927/// Fallback parser for unparseable math expressions.
1928/// Perl: MathParser.pm parse_kludge().
1929/// Balances OPEN/CLOSE delimiters by wrapping matched groups in XMWrap.
1930/// Uses document.wrap_nodes for proper namespace handling.
1931/// Renumber xml:ids inside parsed XMath subtrees so they are sequential in
1932/// document order. The Marpa parser explores multiple parse alternatives,
1933/// consuming ID counter slots for pruned nodes (e.g. m1.1, m1.7, m1.12
1934/// instead of m1.1, m1.2, m1.3). This pure post-processing pass reassigns
1935/// IDs after all pruning is complete.
1936///
1937/// Optimized: single DFS walk per XMath (not XPath), O(1) parent-prefix
1938/// lookup via ID string parsing, and allocation reuse across Math nodes.
1939fn renumber_math_ids(document: &mut Document) {
1940  let xml_ns = "http://www.w3.org/XML/1998/namespace";
1941  let math_nodes = document.findnodes("descendant-or-self::ltx:Math[@text]", None);
1942
1943  // Reuse allocations across Math nodes
1944  let mut id_entries: Vec<(libxml::tree::Node, String)> = Vec::new();
1945  let mut idref_entries: Vec<(libxml::tree::Node, String)> = Vec::new();
1946  let mut id_map: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
1947  let mut referenced_ids: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1948
1949  for mut math_node in math_nodes {
1950    let math_id = match math_node.get_attribute_ns("id", xml_ns) {
1951      Some(id) => id,
1952      None => continue,
1953    };
1954
1955    let xmath_nodes = document.findnodes("ltx:XMath", Some(&math_node));
1956    for xmath in xmath_nodes {
1957      id_entries.clear();
1958      idref_entries.clear();
1959      id_map.clear();
1960      referenced_ids.clear();
1961
1962      // Single DFS walk collects both xml:id and idref nodes in document order
1963      renumber_collect_dfs(&xmath, xml_ns, &mut id_entries, &mut idref_entries);
1964      if id_entries.is_empty() {
1965        continue;
1966      }
1967
1968      // Collect all referenced IDs (from XMRef idref attributes)
1969      for (_, idref) in &idref_entries {
1970        referenced_ids.insert(idref.clone());
1971      }
1972
1973      // Strip xml:id from XMTok elements that are not referenced by any XMRef.
1974      // The math parser assigns xml:ids to all tokens during parsing, but only
1975      // structural nodes (XMApp, XMDual) and explicitly referenced tokens need them.
1976      // Orphan XMTok ids inflate the renumbering counter causing ID gaps.
1977      {
1978        let mut stripped = false;
1979        for (node, id) in &mut id_entries {
1980          if node.get_name() == "XMTok" && !referenced_ids.contains(id.as_str()) {
1981            document.unrecord_id(id);
1982            let _ = node.remove_attribute("xml:id");
1983            let _ = node.remove_attribute_ns("id", xml_ns);
1984            id.clear(); // mark for removal
1985            stripped = true;
1986          }
1987        }
1988        if stripped {
1989          id_entries.retain(|(_, id)| !id.is_empty());
1990        }
1991      }
1992
1993      if id_entries.is_empty() {
1994        continue;
1995      }
1996
1997      // Build old→new mapping. Flat sequential numbering under the math_id prefix,
1998      // matching Perl's approach of assigning all IDs at the same level.
1999      let mut counter = 0u32;
2000      let mut any_changed = false;
2001      for (_node, old_id) in &id_entries {
2002        counter += 1;
2003        let new_id = format!("{math_id}.{counter}");
2004        if new_id != *old_id {
2005          any_changed = true;
2006        }
2007        id_map.insert(old_id.clone(), new_id);
2008      }
2009
2010      if !any_changed {
2011        continue;
2012      }
2013
2014      // Apply new xml:ids in TWO passes to avoid idstore collisions.
2015      // A new id like "m1.1" would collide with an old "m1.1" still in the
2016      // idstore if we interleave unrecord+record. Strip all first, then assign.
2017      let mut nodes_to_update: Vec<(libxml::tree::Node, String)> = Vec::new();
2018      for (mut node, old_id) in id_entries.drain(..) {
2019        if let Some(new_id) = id_map.get(&old_id)
2020          && new_id != &old_id
2021        {
2022          document.unrecord_id(&old_id);
2023          let _ = node.remove_attribute("xml:id");
2024          let _ = node.remove_attribute_ns("id", xml_ns);
2025          nodes_to_update.push((node, new_id.clone()));
2026        }
2027      }
2028      for (mut node, new_id) in nodes_to_update {
2029        let _ = document.set_attribute(&mut node, "xml:id", &new_id);
2030      }
2031
2032      // Update idrefs
2033      for (mut node, old_idref) in idref_entries.drain(..) {
2034        if let Some(new_idref) = id_map.get(&old_idref)
2035          && new_idref != &old_idref
2036        {
2037          let _ = node.set_attribute("idref", new_idref);
2038        }
2039      }
2040
2041      // Reset _ID_counter__ on the Math node to the final count
2042      let _ = math_node.set_attribute("_ID_counter__", &counter.to_string());
2043    }
2044  }
2045}
2046
2047/// DFS walk collecting nodes with xml:id and idref attributes in document order.
2048/// Stops at nested `Math` elements (which have their own parsing scope).
2049fn renumber_collect_dfs(
2050  node: &libxml::tree::Node,
2051  xml_ns: &str,
2052  id_entries: &mut Vec<(libxml::tree::Node, String)>,
2053  idref_entries: &mut Vec<(libxml::tree::Node, String)>,
2054) {
2055  if let Some(id) = node.get_attribute_ns("id", xml_ns) {
2056    id_entries.push((node.clone(), id));
2057  }
2058  if let Some(idref) = node.get_attribute("idref") {
2059    idref_entries.push((node.clone(), idref));
2060  }
2061  for child in node.get_child_elements() {
2062    // Skip nested Math elements — they have their own parsing scope
2063    if child.get_name() == "Math" {
2064      continue;
2065    }
2066    renumber_collect_dfs(&child, xml_ns, id_entries, idref_entries);
2067  }
2068}
2069
2070#[cfg(test)]
2071mod tests {
2072  use super::{LATEXML_VERSION, parse_preload_spec};
2073
2074  fn opts(v: &[&str]) -> Vec<String> { v.iter().map(|s| s.to_string()).collect() }
2075
2076  /// #320: `LATEXML_VERSION` must be a bare `X.Y.Z` (three integer components,
2077  /// no `-rc`/pre-release) so BookML's version-gate parse works. Guards against a
2078  /// future "simplify to `env!(\"CARGO_PKG_VERSION\")`" that would re-add `-rc1`.
2079  #[test]
2080  fn latexml_version_is_bare_xyz() {
2081    let parts: Vec<&str> = LATEXML_VERSION.split('.').collect();
2082    assert_eq!(
2083      parts.len(),
2084      3,
2085      "LATEXML_VERSION must be X.Y.Z, got {LATEXML_VERSION:?}"
2086    );
2087    for p in &parts {
2088      assert!(
2089        !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()),
2090        "component {p:?} is not a bare integer in {LATEXML_VERSION:?}"
2091      );
2092    }
2093  }
2094
2095  #[test]
2096  fn preload_no_brackets_no_ext() {
2097    assert_eq!(
2098      parse_preload_spec("latexml"),
2099      ("latexml".into(), "sty".into(), opts(&[]))
2100    );
2101  }
2102
2103  #[test]
2104  fn preload_no_brackets_with_ext() {
2105    assert_eq!(
2106      parse_preload_spec("ar5iv.sty"),
2107      ("ar5iv".into(), "sty".into(), opts(&[]))
2108    );
2109    assert_eq!(
2110      parse_preload_spec("TeX.pool"),
2111      ("TeX".into(), "pool".into(), opts(&[]))
2112    );
2113  }
2114
2115  #[test]
2116  fn preload_front_brackets_with_options() {
2117    // The historical-bug fixture: front-bracket form must produce a real name.
2118    assert_eq!(
2119      parse_preload_spec("[ids,mathlexemes]latexml.sty"),
2120      (
2121        "latexml".into(),
2122        "sty".into(),
2123        opts(&["ids", "mathlexemes"])
2124      )
2125    );
2126    assert_eq!(
2127      parse_preload_spec("[dvipsnames]color.sty"),
2128      ("color".into(), "sty".into(), opts(&["dvipsnames"]))
2129    );
2130  }
2131
2132  #[test]
2133  fn preload_class_with_options() {
2134    assert_eq!(
2135      parse_preload_spec("[twocolumn,11pt]article.cls"),
2136      ("article".into(), "cls".into(), opts(&["twocolumn", "11pt"]))
2137    );
2138  }
2139
2140  #[test]
2141  fn preload_options_trimmed_and_empty_stripped() {
2142    assert_eq!(
2143      parse_preload_spec("[ a , b ,, c ]name.sty"),
2144      ("name".into(), "sty".into(), opts(&["a", "b", "c"]))
2145    );
2146  }
2147
2148  #[test]
2149  fn preload_empty_brackets() {
2150    assert_eq!(
2151      parse_preload_spec("[]name.sty"),
2152      ("name".into(), "sty".into(), opts(&[]))
2153    );
2154  }
2155
2156  #[test]
2157  fn preload_unmatched_bracket_falls_through() {
2158    // No closing `]` ⇒ treat the whole spec as the base, no options.
2159    assert_eq!(
2160      parse_preload_spec("[opt"),
2161      ("[opt".into(), "sty".into(), opts(&[]))
2162    );
2163  }
2164
2165  #[test]
2166  fn preload_dot_in_name_uses_last_segment_as_ext() {
2167    assert_eq!(
2168      parse_preload_spec("foo.bar.sty"),
2169      ("foo.bar".into(), "sty".into(), opts(&[]))
2170    );
2171  }
2172}