latexml/converter.rs
1use std::rc::Rc;
2
3use latexml_core::{
4 Core, CoreOptions, Debug, Error, Fatal, Info, Note,
5 common::{
6 BindingDispatcher, BindingSource, Config, DataSize, DigestionMode, OutputFormat, arena,
7 error::*, object::Object,
8 },
9 digested::Digested,
10 document::Document,
11 list::List,
12 report_mut, s,
13 state::{add_binding_names, set_bindings_dispatch, source_map_enabled, source_table_snapshot},
14 telemetry::{self, Phase},
15};
16
17use crate::core_interface::DigestionAPI;
18
19const CONVERTER_IDENTITY: &str = "latexml_oxide (v0.5.0)";
20
21/// Where a runtime `.rhai` binding may be looked up — the two tiers differ in
22/// *cost* and in *authority*, so they sit at opposite ends of the dispatch
23/// chain (see [`install_binding_dispatch`]).
24#[cfg(feature = "runtime-bindings")]
25#[derive(Clone, Copy)]
26enum RhaiScope {
27 /// The local search paths only (source directory + `--path`) — a cheap
28 /// `pathname::find`, no kpsewhich. Checked FIRST, so a `.rhai` a user put
29 /// beside their document *overrides* a compiled binding of the same name.
30 LocalPaths,
31 /// Additionally the host TeX tree, via kpsewhich (`$TEXINPUTS`). Checked
32 /// LAST, only once every compiled dispatcher has declined (#345).
33 TeXTree,
34}
35
36/// Resolve a runtime `<request>.rhai` binding within `scope` and load it if
37/// present. Returns `None` when no such file exists, so the caller falls
38/// through to the next tier of the chain.
39/// See `docs/parity/script_bindings_plan.md` §7.
40#[cfg(feature = "runtime-bindings")]
41fn rhai_dispatch(request: &str, scope: RhaiScope) -> Option<Result<BindingSource>> {
42 use latexml_core::{
43 binding::content::{FindFileOptions, find_file},
44 state::record_opened_source,
45 };
46 let path = find_file(
47 request,
48 Some(FindFileOptions {
49 // Append `.rhai`, so `foo.sty` resolves `foo.sty.rhai`.
50 ext_type: Some("rhai".into()),
51 // `TeXTree` lets the search fall through to kpsewhich, which is what
52 // honours `$TEXINPUTS` — so a `<pkg>.sty.rhai` distributed in a texmf
53 // tree is found by `\usepackage{pkg}` with no `--path` (#345). kpsewhich
54 // locates it fine (the extension is irrelevant to a `//` recursive
55 // search). This tier is deliberately NOT used for the first-priority
56 // probe: that one runs on EVERY package/class request (64 of them on a
57 // plain acmart paper), and a kpathsea miss is a directory-tree probe —
58 // or a full fork-exec on the subprocess-`kpsewhich` backend. The memo
59 // in `pathname::kpsewhich` is keyed by candidate name, so distinct
60 // package names never share a hit.
61 search_paths_only: matches!(scope, RhaiScope::LocalPaths),
62 ..FindFileOptions::default()
63 }),
64 )?;
65 // Pin the resolved `.rhai` in the opened-sources read-log. `load_file`
66 // below reads it with a raw `std::fs::read_to_string` (it is not opened
67 // through a `Mouth`, so `Mouth::create`'s `record_opened_source` never
68 // fires for it). Without this, an edited binding is invisible to the warm
69 // LSP preamble cache (`warmup_dep_snapshot` / `deps_still_current`) and the
70 // stale macros survive every reconversion. Recording the resolved path lets
71 // the cache invalidate on the file's mtime change.
72 record_opened_source(arena::pin(&path));
73 // #560: report the resolved on-disk path as the load's `BindingSource`, so
74 // the "(Loading …)" note names the real `.rhai` file rather than the
75 // synthesized `<name>_sty.rs` compiled-module proxy name — more useful, and
76 // closer to Perl, which names the actual binding file.
77 Some(latexml_contrib::script_bindings::load_file(&path).map(|_| Some(path)))
78}
79
80/// Install the binding-resolution **priority chain** as the single dispatcher
81/// and register the `latexml_package` binding-name registry.
82///
83/// This is the one definition of binding-resolution policy, shared by
84/// [`Converter::initialize_session`] and the integration-test harness
85/// (`util::test::process_texfile`) — so a test exercises the *same* chain a real
86/// conversion does, and local `.rhai` fixtures are discovered identically.
87///
88/// Priority, highest first (installed in ONE slot, so call-site ordering can't
89/// reshuffle it):
90/// 1. a local `<request>.rhai` in the search paths — lets a user (or a test
91/// fixture next to its `.tex`) OVERRIDE any compiled binding of the same
92/// name (e.g. `article.cls.rhai` shadows `article_cls`). `runtime-bindings`
93/// only.
94/// 2. the `extra` dispatcher (`latexml_contrib` for our binaries) — consulted
95/// before `latexml_package` to preserve the prior external-before-internal
96/// order; the two registries are disjoint, so the order is immaterial.
97/// 3. `latexml_package` — core compiled engine bindings.
98/// 4. a `<request>.rhai` on the host TeX tree (`$TEXINPUTS`, via kpsewhich) —
99/// a binding *distributed* with a package, so it FILLS A GAP rather than
100/// overriding: `\usepackage{X}` finds `X.sty.rhai` in a texmf tree with no
101/// `--path` (#345), while a stray `amsmath.sty.rhai` left on that tree
102/// cannot silently displace the compiled `amsmath` binding. Last, so the
103/// kpathsea probe is paid only by requests nothing else could answer.
104/// `runtime-bindings` only.
105pub(crate) fn install_binding_dispatch(extra: Option<BindingDispatcher>) {
106 set_bindings_dispatch(Rc::new(move |request: &str| {
107 #[cfg(feature = "runtime-bindings")]
108 if let Some(result) = rhai_dispatch(request, RhaiScope::LocalPaths) {
109 return Some(result);
110 }
111 // The compiled tiers (contrib `extra`, then `latexml_package`) load
112 // in-memory bindings with no source file, so they report `None` source.
113 if let Some(extra) = extra.as_ref()
114 && let Some(result) = extra(request)
115 {
116 return Some(result.map(|()| None));
117 }
118 if let Some(result) = latexml_package::dispatch(request) {
119 return Some(result.map(|()| None));
120 }
121 #[cfg(feature = "runtime-bindings")]
122 if let Some(result) = rhai_dispatch(request, RhaiScope::TeXTree) {
123 return Some(result);
124 }
125 None
126 }));
127 // Register every (name, ext) binding pair so `find_file(notex=true)` can
128 // resolve compile-time bindings across all extensions
129 // (.cls/.sty/.def/.pool/code.tex/...). This also feeds `load_class`'s
130 // Perl-parity prefix-match fallback (Package.pm L2702-2706) via the
131 // class-filtered `state::get_class_binding_names()` view. Source of
132 // truth: `latexml_package::BINDINGS`.
133 add_binding_names(latexml_package::binding_names());
134}
135
136pub struct ConversionResponse {
137 pub result: Option<String>,
138 pub log: String,
139 pub status: String,
140 pub status_code: usize,
141}
142pub struct Runtime {
143 pub status: String,
144 pub status_code: usize,
145}
146pub struct Converter {
147 runtime: Runtime,
148 ready: bool,
149 opts: Config,
150 core: Core,
151}
152
153impl Converter {
154 pub fn from_config(opts: Config) -> Converter {
155 let core = Core::new(CoreOptions {
156 verbosity: Some(opts.verbosity),
157 include_comments: opts.include_comments.or(Some(false)),
158 strict: opts.strict,
159 include_styles: opts.include_styles,
160 preload: opts.preload.clone(),
161 search_paths: opts.search_paths.clone(),
162 nomathparse: opts.nomathparse,
163 source_map: opts.source_map,
164 // Perl Core.pm L60-61: seed State PERL_INPUT_ENCODING from --inputencoding
165 // (the Mouth reads it per-line to decode source bytes). `None` ⇒ utf-8.
166 input_encoding: opts.inputencoding.clone(),
167 ..CoreOptions::default()
168 });
169 Converter {
170 runtime: Runtime {
171 status: String::new(),
172 status_code: 3,
173 },
174 ready: false,
175 opts,
176 core,
177 }
178 }
179 pub fn initialize_session(&mut self) -> Result<()> {
180 // Install the binding-resolution priority chain (rhai > contrib > package)
181 // — the single source of resolution policy, shared with the integration-test
182 // harness via `install_binding_dispatch`.
183 install_binding_dispatch(self.opts.extra_bindings_dispatch.clone());
184 // Also expose contrib's bindings (memoir / siamltex / scrbook / etc.)
185 // so they participate in the same resolution pool. We unconditionally
186 // register latexml_contrib here because the canonical setup for both
187 // `latexml_oxide` and `cortex_worker` binaries loads it — downstream
188 // embedders that replace the dispatchers can register their own
189 // (name, ext) slice the same way via `add_binding_names`.
190 add_binding_names(latexml_contrib::binding_names());
191 // Prepare LaTeXML object — load mode-specific pool + user preloads.
192 // Perl: $self->initializeState($mode.".pool", @{$$self{preload} || []})
193 // For `--bibtex` (mode = BibTeX), Perl `Common/Config.pm:406`
194 // unshifts ['TeX.pool', 'LaTeX.pool', 'BibTeX.pool'] into the preload
195 // list. `BibTeX.pool` already begins with `LoadPool('LaTeX')` (and
196 // LaTeX with TeX), so we only need the BibTeX entry — the transitive
197 // chain handles the rest, and pool loads are idempotent.
198 let mut preloads = match self.opts.mode {
199 Some(DigestionMode::BibTeX) => vec![s!("TeX.pool"), s!("BibTeX.pool")],
200 _ => vec![s!("TeX.pool")],
201 };
202 preloads.extend(self.core.preload.iter().cloned());
203 self.core.initialize_singletons(preloads)?;
204 // Warm libkpathsea's per-format lazy-init tables before the first file
205 // lookup, matching the CLI binaries (`latexml_oxide.rs` / `cortex_worker.rs`
206 // spawn this at startup). The library entry — tests via the trip harness,
207 // `latexml::api`, and downstream embedders — otherwise skips that prewarm,
208 // so libkpathsea's lazy `kpathsea_init_format` runs *during* the first
209 // lookups. When many conversion threads share the one process-global
210 // kpathsea handle under load (e.g. `cargo test --tests`), that mid-flight
211 // lazy init can transiently mis-resolve a support file → a spurious, flaky
212 // "1 warning". Running it inline here guarantees the tables are complete
213 // before `convert()` looks anything up, on every thread. Idempotent and a
214 // fast no-op once warm; single-process/single-thread runs (the CLI, the
215 // one-conversion-per-process cortex fleet) are unaffected. Honours the
216 // CLI's `LATEXML_NO_KPATHSEA_PREWARM` benchmarking opt-out.
217 if std::env::var_os("LATEXML_NO_KPATHSEA_PREWARM").is_none() {
218 latexml_core::util::pathname::prewarm_kpathsea();
219 }
220 // Record which file-resolution backend this process resolved, so every log
221 // carries it. A dead or degraded kpathsea is otherwise invisible — it looks
222 // exactly like a document referencing files that do not exist — and issue
223 // #304 cost days for want of this one line in the reporter's log.
224 let (backend, why) = latexml_core::util::pathname::kpathsea_backend();
225 Info!("kpathsea", "backend", s!("{} ({why})", backend.as_str()));
226 self.ready = true;
227 Ok(())
228 }
229
230 pub fn bind_log(&mut self) { latexml_core::util::logger::bind_log(); }
231 pub fn flush_log(&mut self) -> String { latexml_core::util::logger::flush_log() }
232
233 pub fn convert(mut self, source: String) -> ConversionResponse {
234 // 1 Prepare for conversion
235 // 1.1 Initialize session if needed:
236 if !self.ready {
237 let _g_bootstrap = telemetry::phase(Phase::Bootstrap);
238 if let Err(e) = self.initialize_session() {
239 // We can't initialize, return error:
240 e.log_fatal();
241 }
242 drop(_g_bootstrap);
243 if !self.ready {
244 return ConversionResponse {
245 result: None,
246 log: self.flush_log(),
247 status: s!("Initialization failed."),
248 status_code: 3,
249 };
250 }
251 }
252
253 self.bind_log();
254 // 1.2 Inform of identity, increase conversion counter
255 if self.opts.verbosity >= 0 {
256 Note!(CONVERTER_IDENTITY);
257 // info!( "invoked as [$0 " . join(' ', @ARGV) . "]\n" if $$opts{verbosity} >= 1;
258 // info!("processing started " . localtime() . "\n"; )
259 }
260
261 // 1.3 Prepare for What's IN:
262 // - We use a new temporary variable to avoid confusion with daemon caching
263 // - Math needs to magically trigger math mode if needed
264 // - Fragments need to have a default pre- and postamble, if none provided
265 // Perl LaTeXML.pm:165-172 keys BOTH ambles on `whatsin`; see
266 // `resolve_amble`. (The previous inline code keyed the postamble on
267 // `whatsout`, dropping `\end{document}` / `\ensuremathpreceeds` for
268 // fragment/math inputs.)
269 let (current_preamble, current_postamble) = resolve_amble(
270 &self.opts.whatsin,
271 &self.opts.preamble,
272 &self.opts.postamble,
273 );
274 // TODO:
275 // 1.3.3 Archives need to get unpacked in a sandbox (with sufficient bookkeeping)
276 // elsif ($$opts{whatsin} =~ /^archive/) {
277 // // Sandbox the input
278 // $$opts{archive_sourcedirectory} = $$opts{sourcedirectory};
279 // my $sandbox_directory = File::Temp->newdir(TMPDIR => 1);
280 // $$opts{sourcedirectory} = $sandbox_directory;
281 // // Extract the archive in the sandbox
282 // $source = unpack_source($source, $sandbox_directory);
283 // if (!defined $source) { // Unpacking failed to find a source
284 // $$opts{sourcedirectory} = $$opts{archive_sourcedirectory};
285 // my $log = $self->flush_log;
286 // return { result => undef, log => $log, status => "Fatal:IO:Archive Can't detect a
287 // source TeX file!", status_code => 3 }; } // Destination magic: If we expect an archive
288 // on output, we need to invent the appropriate destination ourselves when not given.
289 // // Since the LaTeXML API never writes the final archive file to disk, we just use a pretend
290 // sourcename.zip: if (($$opts{whatsout} =~ /^archive/) && (!$$opts{destination})) {
291 // $$opts{placeholder_destination} = 1;
292 // $$opts{destination} = pathname_name($source) . ".zip"; } }
293
294 // // 1.4 Prepare for What's OUT (if we need a sandbox)
295 // if ($$opts{whatsout} =~ /^archive/) {
296 // $$opts{archive_sitedirectory} = $$opts{sitedirectory};
297 // $$opts{archive_destination} = $$opts{destination};
298 // my $destination_name = $$opts{destination} ? pathname_name($$opts{destination}) :
299 // 'document'; my $sandbox_directory = File::Temp->newdir(TMPDIR => 1);
300 // my $extension = $$opts{format};
301 // $extension =~ s/\d+$//;
302 // $extension =~ s/^epub|mobi$/xhtml/;
303 // my $sandbox_destination = "$destination_name.$extension";
304 // $$opts{sitedirectory} = $sandbox_directory;
305
306 // if ($$opts{format} eq 'epub') {
307 // $$opts{resource_directory} = File::Spec->catdir($sandbox_directory, 'OPS');
308 // $$opts{destination} = pathname_concat(File::Spec->catdir($sandbox_directory, 'OPS'),
309 // $sandbox_destination); } else {
310 // $$opts{destination} = pathname_concat($sandbox_directory, $sandbox_destination); }
311 // }
312
313 // 1.5 Prepare a daemon frame
314 // ...
315
316 // 2 Beginning Core conversion - digest the source:
317 // my ($digested, $dom, $serialized) = (undef, undef, undef);
318 // Should be this, but is overridden by withState.
319 // local $SIG{'ALRM'} = sub { LaTeXML::Common::Error::Fatal('conversion','timeout',
320 // "Conversion timed out after " . $$opts{timeout} . " seconds!\n"); };
321 // alarm($$opts{timeout});
322 // my $mode = ($$opts{type} eq 'auto') ? 'TeX' : $$opts{type};
323 // Streaming (fragmented) conversion: digest and build interleave inside
324 // `convert_streaming`, so the eager digest-then-build sequence below does
325 // not apply. TeX/Box outputs revert to eager — they serialize the DIGESTED
326 // list and never build a DOM, so there is nothing to fragment.
327 if let Some(budget) = self.opts.streaming
328 && !matches!(self.opts.format, OutputFormat::TeX | OutputFormat::Box)
329 {
330 let dom_result = {
331 let _g = telemetry::phase(Phase::Build);
332 self.core.convert_streaming(
333 source,
334 current_preamble,
335 current_postamble,
336 self.opts.mode.clone(),
337 budget,
338 )
339 };
340 let serialized = match dom_result {
341 Ok(dom) => {
342 let _g = telemetry::phase(Phase::Serialize);
343 dom.serialize_to_string()
344 },
345 Err(e) => {
346 // Same resource-fatal surfacing as the eager DOM arm below.
347 if matches!(e.target, ErrorTarget::Timeout) {
348 e.log_fatal();
349 } else {
350 let message = s!("{:?}", e);
351 let err = || {
352 Error!("document", "convert", message);
353 Ok(())
354 };
355 err().ok();
356 }
357 String::new()
358 },
359 };
360 // The SHARED tail, not a hand-copy of parts of it: this arm used to
361 // reproduce only the verdict fold and silently skip the rest — so a
362 // streamed run emitted no MARPA_ASF_STATS line (measured: the 131 MB
363 // witness under MARPA_ASF_STATS=1 produced zero stats), and a streamed
364 // `--source-map` run emitted `data:sourcepos` tags with NO decoder
365 // table in the log. Streaming auto-activates on exactly the large
366 // documents where both matter.
367 return self.finish_response(serialized);
368 }
369
370 let digest_result = {
371 let _g = telemetry::phase(Phase::Digest);
372 self.core.digest(
373 source,
374 current_preamble,
375 current_postamble,
376 self.opts.mode.clone(),
377 true,
378 )
379 };
380 let digested = match digest_result {
381 Err(e) => {
382 report_mut!().status_code = 3;
383 e.log_fatal();
384 // Perl L251-259: If digestion failed, try finishDigestion to salvage
385 // whatever was partially consumed. This allows partial recovery where
386 // the beginning of the document is valid but an error occurs midway.
387 match self.core.digest_internal() {
388 Ok(salvaged) if !salvaged.is_empty().unwrap_or(true) => {
389 Info!(
390 "recovery",
391 "digest",
392 "Salvaged partial output after fatal error"
393 );
394 salvaged
395 },
396 _ => Digested::from(List::new(Vec::new())),
397 }
398 },
399 Ok(d) => d,
400 };
401 // 2.1 Now, convert to DOM and output, if desired.
402 let dom_result: Result<Document>;
403 let serialized = match self.opts.format {
404 OutputFormat::TeX => {
405 let untex_result = { digested.untex() };
406 match untex_result {
407 Ok(tex) => tex,
408 Err(e) => {
409 return ConversionResponse {
410 result: None,
411 log: self.flush_log(),
412 status: s!("fatal:untex:{:?}", e),
413 status_code: 3,
414 };
415 },
416 }
417 },
418 OutputFormat::Box => {
419 if self.opts.verbosity > 0 {
420 digested.stringify()
421 } else {
422 digested.to_string()
423 }
424 },
425 _ => {
426 dom_result = {
427 let _g = telemetry::phase(Phase::Build);
428 self.core.convert_document(digested)
429 };
430 match dom_result {
431 Ok(dom) => {
432 let _g = telemetry::phase(Phase::Serialize);
433 dom.serialize_to_string()
434 },
435 Err(e) => {
436 // A resource fatal (Timeout target — e.g. a cycle-guard abort
437 // propagated out of math parsing, P1-4) must surface as the
438 // standard `Fatal:` log line, not a generic document error;
439 // otherwise the summary counts a fatal the log never shows.
440 if matches!(e.target, ErrorTarget::Timeout) {
441 e.log_fatal();
442 } else {
443 let message = s!("{:?}", e);
444 let err = || {
445 Error!("document", "convert", message);
446 Ok(())
447 };
448 err().ok();
449 }
450 String::new()
451 },
452 }
453 },
454 };
455
456 self.runtime.status = get_status_message();
457 self.runtime.status_code = get_status_code();
458 // alarm(0)
459
460 // 2.2 Bookkeeping in case fatal errors occurred
461 // ...
462
463 // 2.3 Clean up and exit if we only wanted the serialization of the core conversion
464 // if ($serialized) {
465 // // If serialized has been set, we are done with the job
466 // // If we just processed an archive, clean up sandbox directory.
467 // if ($$opts{whatsin} =~ /^archive/) {
468 // rmtree($$opts{sourcedirectory});
469 // $$opts{sourcedirectory} = $$opts{archive_sourcedirectory}; }
470 // my $log = $self->flush_log;
471 // return { result => $serialized, log => $log, status => $$runtime{status}, status_code =>
472 // $$runtime{status_code} }; }
473
474 // 3 If desired, post-process
475 // my $result = $dom;
476 // if ($$opts{post} && $dom && $dom->documentElement) {
477 // my $post_eval_return = eval {
478 // local $SIG{'ALRM'} = sub { die "alarm\n" };
479 // alarm($$opts{timeout});
480 // $result = $self->convert_post($dom);
481 // alarm(0);
482 // 1;
483 // };
484 // // 3.1 Bookkeeping if a post-processing Fatal error occurred
485 // //// $$latexml{state}->noteStatus('fatal') if $latexml && $@; // Fatal Error?
486 // local $@ = 'Fatal:conversion:unknown Post-processing failed! (Unknown Reason)'
487 // if ((!$post_eval_return) && (!$@));
488 // if ($@) { //Fatal occured!
489 // $$runtime{status_code} = 3;
490 // $@ = 'Fatal:conversion:unknown '.$@ unless $@ =~ /^Fatal:/;
491 // error!($@);
492 // //Since this is postprocessing, we don't need to do anything
493 // // just avoid crashing...
494 // $result = undef; } }
495
496 // // 4 Clean-up: undo everything we sandboxed
497 // if ($$opts{whatsin} =~ /^archive/) {
498 // rmtree($$opts{sourcedirectory});
499 // $$opts{sourcedirectory} = $$opts{archive_sourcedirectory}; }
500 // if ($$opts{whatsout} =~ /^archive/) {
501 // rmtree($$opts{sitedirectory});
502 // $$opts{sitedirectory} = $$opts{archive_sitedirectory};
503 // $$opts{destination} = $$opts{archive_destination};
504 // if (delete $$opts{placeholder_destination}) {
505 // delete $$opts{destination}; } }
506
507 // // 5 Output
508 // // 5.1 Serialize the XML/HTML result (or just return the Perl object, if requested)
509 // undef $serialized;
510 // if ((defined $result) && ref($result) && (ref($result) =~ /^(:?LaTe)?XML/)) {
511 // if (($$opts{format} =~ 'x(ht)?ml') || ($$opts{format} eq 'jats')) {
512 // $serialized = $result->to_string(1); }
513 // elsif ($$opts{format} =~ /^html/) {
514 // if (ref($result) =~ '^LaTeXML::(Post::)?Document$') { // Special for documents
515 // $serialized = $result->getDocument->to_stringHTML; }
516 // else { // Regular for fragments
517 // do {
518 // local $XML::LibXML::setTagCompression = 1;
519 // $serialized = $result->to_string(1);
520 // } } }
521 // elsif ($$opts{format} eq 'dom') {
522 // $serialized = $result; } }
523 // else { $serialized = $result; } // Compressed case
524
525 // 5.2 Finalize logging and return a response containing the document result, log and status
526 self.finish_response(serialized)
527 }
528
529 /// The SHARED conversion tail: instrumentation flush (ASF stats, the
530 /// `--source-map` decoder table), the Perl-faithful completion `Note!`, and
531 /// response assembly. Every arm of `convert` must end here — the streaming
532 /// arm used to return early with a hand-copy of the verdict fold alone,
533 /// silently skipping the rest (no `MARPA_ASF_STATS` line, and a streamed
534 /// `--source-map` run emitted `data:sourcepos` tags with no decoder ring).
535 ///
536 /// Recomputes `status`/`status_code` (idempotent reads of the REPORT
537 /// counters), so diagnostics raised during serialization itself still reach
538 /// the reported verdict — the streaming arm always did this; the eager path
539 /// previously froze status before serializing.
540 fn finish_response(&mut self, serialized: String) -> ConversionResponse {
541 self.runtime.status = get_status_message();
542 self.runtime.status_code = get_status_code();
543 if self.opts.verbosity >= 0 {
544 Debug!("arena", "strings_allocated", arena::len());
545 // Final token-read progress: the calibration basis for `token_limit`
546 // and `CYCLE_GUARD_ACTIVATE` (the read-checkpoint accounting changed
547 // in PR #249 — read_x_token/read_balanced now count too — so limits
548 // must be recalibrated against THIS metric, not historical figures).
549 Debug!("gullet", "progress", latexml_core::gullet::final_progress());
550 }
551 // MARPA_ASF_STATS=1: emit ASF instrumentation counters once
552 // per converted document. Codex instrumentation plan, see
553 // marpa/docs/ASF_PERFORMANCE_FINDINGS.md. The thread-local
554 // accumulator is reset after the snapshot so per-document
555 // figures are independent.
556 latexml_math_parser::report_and_reset_asf_stats();
557 // --source-map (#47/#92): serialise the `tag → file` decoder table into the
558 // `.log` — latexml-oxide's existing conversion-metadata channel — rather than
559 // inlining it into the output. The output carries only the anonymous integer
560 // `tag` (in each `data:sourcepos`); this is its decoder ring, Source-Map-v3
561 // `sources`-style (the array index *is* the tag). Keeping it out of the
562 // HTML/XML keeps that output anonymisable: a consumer without the source
563 // files sees only opaque tags. In-process embedders (e.g. the ar5iv-editor
564 // server) read the same table programmatically via `source_table_snapshot()`.
565 // Gated on the switch, so a normal conversion emits nothing.
566 if source_map_enabled() {
567 for (tag, sym) in source_table_snapshot().iter().enumerate() {
568 arena::with(*sym, |src| {
569 Info!("source-map", "source", s!("[{tag}] {src}"));
570 });
571 }
572 }
573 // Perl: Note("Conversion complete: " . $$runtime{status}); (LaTeXML.pm:315)
574 // is reached only on success — a Fatal `die`s before it, and bin/latexml:127
575 // then prints `"Conversion " . ($code == 3 ? 'failed' : 'complete')`. Rust
576 // recovers from a Fatal (graceful degradation) instead of dying, so it reaches
577 // this note even when status_code == 3; fold in bin/latexml's verdict here so
578 // a fatal run reports "failed", never the self-contradictory "complete: N fatal
579 // error". Success cases (status_code < 3) stay byte-identical.
580 Note!(s!("{}", conversion_verdict(self.runtime.status_code)));
581 let log = self.flush_log();
582 // self->sanitize($log) if ($$runtime{status_code} == 3);
583
584 ConversionResponse {
585 result: Some(serialized),
586 log,
587 status: self.runtime.status.clone(),
588 status_code: self.runtime.status_code,
589 }
590 }
591
592 /// Convert in-memory `content` under the source name `name`, producing the
593 /// HTML5-format core XML (the persistent server then post-processes it).
594 /// Unlike [`Converter::convert`] (`literal:` → anonymous source), the source
595 /// is *named*, so `--source-map` stamps its locators. Focused on the
596 /// `Document`/HTML5 path the server uses — no amble wrapping, no TeX/Box
597 /// output formats.
598 pub fn convert_content_with_provenance(
599 mut self,
600 name: &str,
601 content: String,
602 ) -> ConversionResponse {
603 // Load + digest through the shared top-level loader, so the source-context
604 // setup is not duplicated here.
605 let digested = match self.digest_content_with_provenance(name, content) {
606 Ok(d) => d,
607 Err(e) => {
608 if !self.ready {
609 return ConversionResponse {
610 result: None,
611 log: self.flush_log(),
612 status: s!("Initialization failed."),
613 status_code: 3,
614 };
615 }
616 report_mut!().status_code = 3;
617 e.log_fatal();
618 // Salvage whatever digested before the error (mirrors `convert`).
619 match self.core.digest_internal() {
620 Ok(salvaged) if !salvaged.is_empty().unwrap_or(true) => salvaged,
621 _ => Digested::from(List::new(Vec::new())),
622 }
623 },
624 };
625
626 self.runtime.status = get_status_message();
627 self.runtime.status_code = get_status_code();
628
629 let serialized = {
630 let _g = telemetry::phase(Phase::Build);
631 match self.core.convert_document(digested) {
632 Ok(dom) => {
633 let _g = telemetry::phase(Phase::Serialize);
634 dom.serialize_to_string()
635 },
636 Err(e) => {
637 // `Error!` expands into a `Result`-returning context; wrap it the
638 // same way `convert` does so it composes in this `-> ConversionResponse` fn.
639 // Timeout-target resource fatals get the standard `Fatal:` line
640 // (see the sibling handler in `convert` — P1-4).
641 if matches!(e.target, ErrorTarget::Timeout) {
642 e.log_fatal();
643 } else {
644 let message = s!("{:?}", e);
645 let err = || {
646 Error!("document", "convert", message);
647 Ok(())
648 };
649 err().ok();
650 }
651 String::new()
652 },
653 }
654 };
655
656 let log = self.flush_log();
657 ConversionResponse {
658 result: Some(serialized),
659 log,
660 status: self.runtime.status.clone(),
661 status_code: self.runtime.status_code,
662 }
663 }
664
665 pub fn prepare_session<'preplifetime>(
666 &'preplifetime mut self,
667 _opts: &'preplifetime Config,
668 ) -> Result<()> {
669 // Per-conversion cache hygiene: a persistent worker converts many papers
670 // per thread; cwd-relative kpsewhich results must not leak across them.
671 latexml_core::util::pathname::clear_kpsewhich_memo();
672 if !self.ready {
673 self.initialize_session()?
674 }
675 Ok(())
676 }
677
678 /// Digest in-memory `content` as the **main document** named `name`, leaving
679 /// the thread-local engine state **live**. Used by the persistent server to
680 /// warm a preamble once (then resume body digestion in a fork child over the
681 /// inherited state) and by [`Converter::convert_content_with_provenance`] for
682 /// the in-process path.
683 ///
684 /// This is the in-memory twin of [`crate::core_interface::DigestionAPI::digest_file`]:
685 /// same top-level spine — establish the source context, open the source,
686 /// `digest_internal` — but the content is *supplied* rather than read from
687 /// disk. It shares `core_interface::establish_source_context` with
688 /// `digest_file` (so `SOURCEFILE`/`SOURCEDIRECTORY`/`SEARCHPATHS`/
689 /// `GRAPHICSPATHS`/`\jobname` can't drift), making sibling
690 /// `\usepackage`/`\input`/`\includegraphics` of local files resolve. The
691 /// source is opened as a *named* mouth (not the anonymous `literal:`
692 /// protocol) so locators carry `name` — the **provenance** that
693 /// `--source-map` needs (`stamp_source_locator` only stamps
694 /// `.tex`/`.ltx`/`.bbl`/`.bib` user sources). Initializes the session if
695 /// needed; does not finalize a document.
696 pub fn digest_content_with_provenance(
697 &mut self,
698 name: &str,
699 content: String,
700 ) -> Result<Digested> {
701 if !self.ready {
702 self.initialize_session()?;
703 }
704 self.bind_log();
705 // Top-level document load: establish the source context (SOURCEFILE,
706 // SOURCEDIRECTORY, SEARCHPATHS, GRAPHICSPATHS, \jobname) so sibling
707 // \usepackage/\input/\includegraphics of local files resolve. Shared with
708 // `digest_file` via `establish_source_context` so the two can't drift.
709 // (A continuation/nested mouth must NOT do this — see
710 // `open_named_in_memory_mouth`.)
711 let path = std::path::Path::new(name);
712 let dir = path.parent().and_then(|p| p.to_str()).unwrap_or("");
713 let jobname = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
714 crate::core_interface::establish_source_context(Some(name), jobname, dir);
715 open_named_in_memory_mouth(name, content)?;
716 self.core.digest_internal()
717 }
718}
719
720/// Open a gullet mouth over in-memory `content` whose source is named `name`
721/// (a real path/filename). Uses the Mouth's cached-content branch so locators
722/// carry `name` rather than "Anonymous String".
723///
724/// Low-level and *position-agnostic*: it does NOT touch the document-global
725/// `SOURCEDIRECTORY`/`SEARCHPATHS`, so it is safe for a continuation (the
726/// forked child's body over already-inherited state) or a nested include. For
727/// the *main* document load, go through `Converter::digest_named`, which
728/// installs the document directory first.
729pub fn open_named_in_memory_mouth(name: &str, content: String) -> Result<()> {
730 use latexml_core::{
731 gullet,
732 mouth::{Mouth, MouthOptions},
733 };
734 let mouth = Mouth::create(name, MouthOptions {
735 notes: true,
736 content: Some(content),
737 ..MouthOptions::default()
738 })?;
739 gullet::open_mouth(mouth, true);
740 Ok(())
741}
742
743/// Resolve the `(preamble, postamble)` to wrap the source in, based on
744/// the requested input chunk size. Faithful port of Perl `LaTeXML.pm`
745/// L165-172 — note both ambles key on **`whatsin`** (not `whatsout`):
746///
747/// * `math` → `\begin{document}\ensuremathfollows` … `\ensuremathpreceeds\end{document}` (magic
748/// math-mode trigger).
749/// * `fragment` → the caller-supplied `preamble`/`postamble`, defaulting to `standard_preamble.tex`
750/// / `standard_postamble.tex`.
751/// * everything else (`document`, `archive`, …) → no wrapping.
752pub(crate) fn resolve_amble(
753 whatsin: &DataSize,
754 preamble: &Option<String>,
755 postamble: &Option<String>,
756) -> (Option<String>, Option<String>) {
757 match whatsin {
758 DataSize::Math => (
759 Some(s!("literal:\\begin{{document}}\\ensuremathfollows")),
760 Some(s!("literal:\\ensuremathpreceeds\\end{{document}}")),
761 ),
762 DataSize::Fragment => (
763 Some(
764 preamble
765 .clone()
766 .unwrap_or_else(|| s!("standard_preamble.tex")),
767 ),
768 Some(
769 postamble
770 .clone()
771 .unwrap_or_else(|| s!("standard_postamble.tex")),
772 ),
773 ),
774 _ => (None, None),
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
783 fn amble_math_wraps_both_ends() {
784 // Perl LaTeXML.pm:166-168 — math sets BOTH preamble and postamble.
785 let (pre, post) = resolve_amble(&DataSize::Math, &None, &None);
786 assert_eq!(
787 pre.as_deref(),
788 Some("literal:\\begin{document}\\ensuremathfollows")
789 );
790 assert_eq!(
791 post.as_deref(),
792 Some("literal:\\ensuremathpreceeds\\end{document}")
793 );
794 }
795
796 #[test]
797 fn amble_fragment_defaults_to_standard_files() {
798 let (pre, post) = resolve_amble(&DataSize::Fragment, &None, &None);
799 assert_eq!(pre.as_deref(), Some("standard_preamble.tex"));
800 assert_eq!(post.as_deref(), Some("standard_postamble.tex"));
801 }
802
803 #[test]
804 fn amble_fragment_honors_explicit_files() {
805 let (pre, post) = resolve_amble(
806 &DataSize::Fragment,
807 &Some("my_pre.tex".into()),
808 &Some("my_post.tex".into()),
809 );
810 assert_eq!(pre.as_deref(), Some("my_pre.tex"));
811 assert_eq!(post.as_deref(), Some("my_post.tex"));
812 }
813
814 #[test]
815 fn amble_document_and_archive_have_no_wrapping() {
816 assert_eq!(
817 resolve_amble(&DataSize::Document, &None, &None),
818 (None, None)
819 );
820 assert_eq!(
821 resolve_amble(&DataSize::Archive, &None, &None),
822 (None, None)
823 );
824 }
825}