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