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