Skip to main content

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}