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}