Skip to main content

latexml/
post.rs

1//! Post-processing pipeline API.
2//!
3//! Provides a public interface to the LaTeXML post-processing pipeline
4//! (Scan → Bibliography → CrossRef → Graphics → Split → MathML → XSLT → HTML5 fixups).
5//! Used by both the `latexml_oxide` binary and the `cortex_worker` binary.
6
7use latexml_core::{
8  Info, Warn,
9  common::error::{emit_error, note_progress},
10  s,
11  telemetry::{self, Phase},
12};
13use latexml_post::{
14  document::{PostDocument, PostDocumentOptions},
15  object_db::ObjectDB,
16  processor::Processor,
17};
18use once_cell::sync::Lazy;
19
20// Process-once cached env var (see WISDOM #56 — getenv hot-path race).
21static POST_AUDIT: Lazy<bool> = Lazy::new(|| std::env::var("LATEXML_POST_AUDIT").is_ok());
22
23/// Start an audit timer when `LATEXML_POST_AUDIT` is set. Module-level (not a
24/// closure) so [`render_spilled_page`] — shared by the serial driver and the
25/// parallel render worker — can use the identical instrumentation.
26fn audit_start(name: &str) -> Option<(String, std::time::Instant)> {
27  if *POST_AUDIT {
28    Some((name.to_string(), std::time::Instant::now()))
29  } else {
30    None
31  }
32}
33
34/// Report an audit timer started by [`audit_start`] (no-op when audit is off).
35fn audit_end(started: Option<(String, std::time::Instant)>) {
36  if let Some((name, t0)) = started {
37    let ms = t0.elapsed().as_millis();
38    Info!("audit", "phase", s!("{} took {}ms", name, ms));
39  }
40}
41
42/// Options for the post-processing pipeline.
43pub struct PostOptions<'a> {
44  pub pmml:                      bool,
45  pub cmml:                      bool,
46  pub keep_xmath:                bool,
47  pub stylesheet:                Option<&'a str>,
48  pub destination:               Option<&'a str>,
49  /// Base directory of the original LaTeX source (`--sourcedirectory`; Perl
50  /// Config.pm L147, passed to Post as `sourceDirectory` in LaTeXML.pm L429).
51  /// Used to locate graphics/resources the post-phase copies. When `None`,
52  /// the caller defaults it to the source file's own directory.
53  pub source_directory:          Option<&'a str>,
54  /// Root directory of the generated site (`--sitedirectory`; Perl Config.pm
55  /// L146, passed to Post as `siteDirectory` in LaTeXML.pm L430). Establishes
56  /// the base against which cross-document/resource URLs are made relative.
57  /// When `None`, Post defaults it to the destination's directory (Perl
58  /// Config.pm L466-469; `document.rs` mirrors that fallback).
59  pub site_directory:            Option<&'a str>,
60  /// Extra resource search paths (the `--path` flag): directories searched
61  /// for `--css`/`--javascript` files (and other post resources) to copy into
62  /// the destination, in addition to the document's own paths, the current
63  /// directory, and the binary's embedded resource table.
64  pub search_paths:              &'a [String],
65  pub nodefaultresources:        bool,
66  pub css_files:                 &'a [String],
67  pub js_files:                  &'a [String],
68  pub noinvisibletimes:          bool,
69  /// Remap styled alphanumerics to Unicode Plane-1 (Perl `--plane1`, default on).
70  pub plane1:                    bool,
71  /// Remap only the poorly-supported variants, to their simpler form
72  /// (Perl `--hackplane1`); implies `plane1`.
73  pub hackplane1:                bool,
74  pub mathtex:                   bool,
75  pub navigationtoc:             Option<&'a str>,
76  pub schemadocs:                bool,
77  pub split:                     bool,
78  pub split_xpath:               Option<String>,
79  pub split_naming:              Option<&'a str>,
80  pub xslt_parameters:           &'a [String],
81  /// If > 0, try the vector-SVG converters (mutool → pdftocairo) for PDF
82  /// graphics smaller than this many KB (vector-preservation path). Fall
83  /// back to the raster ImageMagick `convert`/`gs` → PNG path on failure
84  /// or timeout. Tracks upstream brucemiller/LaTeXML#902.
85  pub graphics_svg_threshold_kb: u32,
86  /// Convert `\includegraphics` figures to web images (Perl `graphicimages!`
87  /// → `dographics`, default on). When `false` (`--nographicimages`) the
88  /// Graphics post-phase is skipped entirely: the output keeps the raw
89  /// `<ltx:graphics>` references untouched — useful for faster runs or hosts
90  /// without the image tools.
91  pub graphicimages:             bool,
92  /// Timestamp string embedded in the generated page (Perl `--timestamp`,
93  /// XSLT `TIMESTAMP` param). `None` → no timestamp emitted (the deterministic
94  /// default; Perl instead defaults to the current time). Perl's
95  /// `--timestamp=0` "omit" case is normalized to `None` by the caller.
96  pub timestamp:                 Option<&'a str>,
97  /// Favicon resource for the generated site (Perl `--icon`, XSLT `ICON`
98  /// param): emitted as a `<link rel="icon">` and copied to the destination.
99  pub icon:                      Option<&'a str>,
100  /// Output extraction mode (Perl `LaTeXML::Util::Pack::whatsout`).
101  /// `Document` (default) → serialize the full post-processed
102  /// document; `Fragment` → embeddable HTML snippet via
103  /// `latexml_post::extract::get_embeddable`; `Math` → math subtree
104  /// via `get_math`. Applied to each `PostDocument` in the final
105  /// serialization loop.
106  pub whatsout:                  latexml_post::extract::Whatsout,
107}
108
109/// The built-in XSLT stylesheet for an output format (logical embedded-resource
110/// name; Perl `Config.pm` L543-551). Returns `None` for a format with no
111/// default (e.g. `xml`), so a caller keeps `--stylesheet` / no post.
112///
113/// SINGLE SOURCE OF TRUTH shared by the CLI (`bin/latexml_oxide.rs` picks
114/// `effective_stylesheet` when `--stylesheet` is unset) and the library
115/// (`crate::api::convert_to_html`), so the two can never disagree on which
116/// sheet an `html5`/`xhtml`/`epub` job gets.
117pub fn default_stylesheet(format: Option<&str>) -> Option<&'static str> {
118  match format {
119    Some("html5") => Some("resources/XSLT/LaTeXML-html5.xsl"),
120    Some("html") | Some("xhtml") => Some("resources/XSLT/LaTeXML-all-xhtml.xsl"),
121    Some("epub") | Some("epub3") => Some("resources/XSLT/LaTeXML-epub3.xsl"),
122    _ => None,
123  }
124}
125
126/// Emit a post-processing error: capture it into the active log buffer (so it
127/// reaches `cortex.log`) AND bump the Error counter so `Status:conversion`
128/// reflects post-phase failures (Perl `LaTeXML.pm` L633: `max(core, post)`).
129///
130/// Deliberately NOT the `Error!` macro: that expands to a too-many-errors
131/// `Fatal!(…)` → `return Err(error::Error)`, which only typechecks inside the
132/// digester's `Result<_, error::Error>` functions. The post-processing entry
133/// points return `String`/`Result<_, ()>`, so we log+count directly with no
134/// control-flow side effect.
135fn post_error(object: &str, message: &str) { emit_error("post", object, message); }
136
137/// Result of [`run_post_processing_logged`]: the serialized HTML plus the
138/// post-phase log text and status code, mirroring Perl `LaTeXML.pm`'s
139/// `convert_post`, which flushes the log AFTER post and folds the post status
140/// into the final `max(core, post)` verdict (L631-634).
141pub struct PostOutcome {
142  /// Serialized post-processed output (same value as [`run_post_processing`]).
143  pub html:        String,
144  /// Log text captured during the post phase only — Graphics/MathML/XSLT
145  /// `Info!`/`Warn!`/`post_error` lines. Append to the core conversion log.
146  pub log:         String,
147  /// Status code read from the shared REPORT counter AFTER post. Because the
148  /// counter is NOT reset between core and post, this is already the combined
149  /// core+post code; callers still `max()` it with the core code as a
150  /// belt-and-suspenders guard for paths that force a fatal outside REPORT
151  /// (e.g. a `catch_unwind`-trapped panic).
152  pub status_code: usize,
153}
154
155/// Run post-processing while capturing its log into a fresh thread-local
156/// LOG_BUFFER. `Converter::convert()` already flushed the *core* log into its
157/// own response, leaving the buffer unbound — so without re-binding here every
158/// post-phase `Info!`/`Warn!`/`post_error` (incl. a silent EPS→PNG failure)
159/// reaches stderr only and never the persisted `--log`/`cortex.log`. Perl's
160/// single `flush_log()` after `convert_post` sweeps up the post log for ALL
161/// consumers; this is the Rust equivalent, shared by the `latexml_oxide` and
162/// `cortex_worker` binaries so their logs reach parity. See SYNC_STATUS task 5.
163pub fn run_post_processing_logged(xml: &str, opts: &PostOptions) -> PostOutcome {
164  run_post_processing_impl_logged(PostInput::Xml(xml), opts)
165}
166
167/// File-input variant of [`run_post_processing_logged`]: parses the XML from
168/// disk via libxml2's streaming reader rather than from an in-memory `String`.
169/// Used for already-converted `.xml` inputs, where slurping the file would keep
170/// a whole extra copy of a multi-hundred-MB document resident alongside the DOM.
171pub fn run_post_processing_from_file_logged(path: &str, opts: &PostOptions) -> PostOutcome {
172  run_post_processing_impl_logged(PostInput::File(path), opts)
173}
174
175fn run_post_processing_impl_logged(input: PostInput, opts: &PostOptions) -> PostOutcome {
176  latexml_core::util::logger::bind_log();
177  let html = run_post_processing_impl(input, opts);
178  let log = latexml_core::util::logger::flush_log();
179  let status_code = latexml_core::common::error::get_status_code();
180  PostOutcome { html, log, status_code }
181}
182
183/// Where the post-pipeline reads its already-converted LaTeXML XML from.
184#[derive(Clone, Copy)]
185enum PostInput<'a> {
186  /// XML already resident in memory (the TeX→XML conversion result). The common
187  /// path — the 2.8M-doc arXiv fleet and every TeX conversion feed this.
188  Xml(&'a str),
189  /// A path to an XML file, parsed via libxml2's streaming file reader
190  /// (`xmlReadIO`). Avoids vivifying a very large document as a Rust `String`
191  /// on top of the ~11× DOM it becomes.
192  File(&'a str),
193}
194
195/// Run the post-processing pipeline on in-memory XML output.
196///
197/// Executes: Split → Scan → MakeBibliography → CrossRef → Graphics → MathML → XSLT → HTML5 fixups.
198pub fn run_post_processing(xml: &str, opts: &PostOptions) -> String {
199  run_post_processing_impl(PostInput::Xml(xml), opts)
200}
201
202/// File-input variant of [`run_post_processing`].
203pub fn run_post_processing_from_file(path: &str, opts: &PostOptions) -> String {
204  run_post_processing_impl(PostInput::File(path), opts)
205}
206
207/// The in-memory parse ceiling: libxml2's `xmlReadMemory` takes the buffer
208/// length as a C `int`, so a handoff string of `i32::MAX` bytes or more
209/// CANNOT be parsed from memory — it fails with "Document too large for
210/// i32". First witness to cross it: the 131 MB book's 2.68 GB core XML,
211/// single-invocation `.tex → .htm` (laptop UAT 2026-07-31; the streamed
212/// assembly exists only as serialized text, so post must re-parse it).
213/// Above the ceiling the handoff spills to a temp file beside the
214/// destination and takes the `PostInput::File` arm — the streaming-reader
215/// path that already parses this very document in the two-invocation flow.
216///
217/// Env-overridable for tests only (`LATEXML_POST_MEM_PARSE_LIMIT`): a real
218/// 2-GiB-plus fixture is not something CI can allocate, so the guard test
219/// drives the spill path with a tiny limit instead.
220fn post_mem_parse_limit() -> usize {
221  std::env::var("LATEXML_POST_MEM_PARSE_LIMIT")
222    .ok()
223    .and_then(|v| v.parse::<usize>().ok())
224    .unwrap_or(i32::MAX as usize)
225}
226
227/// The size at which a *split* run prefers the streaming split front-end
228/// (`latexml_post::stream_split`) over the whole-DOM parse. Default 1 GiB: a
229/// core XML that large parses to a ~20+ GB DOM (measured ~16 GB for 614 MB),
230/// where the streaming front-end peaks at one content subtree. Test override:
231/// `LATEXML_POST_STREAM_THRESHOLD` (bytes).
232fn stream_split_threshold() -> u64 {
233  std::env::var("LATEXML_POST_STREAM_THRESHOLD")
234    .ok()
235    .and_then(|v| v.parse::<u64>().ok())
236    .unwrap_or(1 << 30)
237}
238
239/// Gate for the streaming split: `LATEXML_POST_STREAM_SPLIT=1` forces it on
240/// (any size), `=0` disables it, unset engages it automatically for files at
241/// or above [`stream_split_threshold`] — where the whole-DOM parse is beyond
242/// commodity-RAM hosts anyway (the 131 MB witness's 2.68 GB core XML OOM'd a
243/// 31 GB laptop *during the parse*, 0 pages written).
244fn stream_split_gate(path: &str) -> bool {
245  match std::env::var("LATEXML_POST_STREAM_SPLIT").as_deref() {
246    Ok("0") => false,
247    Ok(_) => true,
248    Err(_) => std::fs::metadata(path)
249      .map(|m| m.len() >= stream_split_threshold())
250      .unwrap_or(false),
251  }
252}
253
254/// `--splitnaming` → `SplitNaming`, shared by the DOM and streaming split
255/// front-ends so the two cannot disagree.
256fn resolve_split_naming(split_naming: Option<&str>) -> latexml_post::split::SplitNaming {
257  use latexml_post::split::SplitNaming;
258  match split_naming {
259    Some("id") | None => SplitNaming::Id,
260    Some("idrelative") => SplitNaming::IdRelative,
261    Some("label") => SplitNaming::Label,
262    Some("labelrelative") => SplitNaming::LabelRelative,
263    Some(other) => {
264      Warn!(
265        "post",
266        "split",
267        format!("Unknown splitnaming '{other}', using 'id'")
268      );
269      SplitNaming::Id
270    },
271  }
272}
273
274/// Destination directory for a page pathname (empty parent → ".", mirroring
275/// `PostDocument`'s internal derivation).
276fn destination_directory_of(destination: &str) -> Option<String> {
277  std::path::Path::new(destination).parent().map(|p| {
278    let s = p.to_string_lossy();
279    if s.is_empty() {
280      ".".to_string()
281    } else {
282      s.into_owned()
283    }
284  })
285}
286
287/// The two placeholder probes recorded per page WHILE it is parsed, so the
288/// ObjectDB-baton sweeps (MakeIndex, MakeBibliography) re-read only the
289/// handful of pages that actually carry work.
290fn page_placeholder_probes(d: &PostDocument) -> (bool, bool) {
291  (
292    !d.findnodes("//ltx:index[not(ltx:indexlist)] | //ltx:glossary[not(ltx:glossarylist)]")
293      .is_empty(),
294    !d.findnodes("//ltx:bibliography").is_empty(),
295  )
296}
297
298/// Everything the shared pipeline tail (MakeIndex → sweeps → page-major
299/// render) needs from a front-end: spilled pages plus the scanned ObjectDB.
300/// Produced by BOTH front-ends — the streaming split and the whole-DOM
301/// parse+Split+Scan.
302struct PostFront {
303  spilled_pages:  Vec<SpilledPage>,
304  db:             ObjectDB,
305  svg_fragments:  Vec<(String, String)>,
306  intent_literal: bool,
307}
308
309/// Attempt the streaming split front-end (`latexml_post::stream_split`).
310///
311/// `Ok(None)` = not applicable (the union matched no pages, or the stream
312/// could not be split faithfully — already reported) → the caller falls back
313/// to the whole-DOM pipeline. `Err(())` = a hard failure after pages were in
314/// flight (I/O, resource ceiling), already reported via `post_error`; the
315/// caller bails out.
316fn try_streaming_front(
317  path: &str,
318  split_xpath: &str,
319  split_naming: Option<&str>,
320  destination: Option<&str>,
321  page_opts: &PostDocumentOptions,
322  spill_dir: &std::path::Path,
323) -> Result<Option<PostFront>, ()> {
324  let naming = resolve_split_naming(split_naming);
325  telemetry::phase_enter(Phase::Split);
326  let outcome =
327    latexml_post::stream_split::stream_split(path, split_xpath, naming, destination, spill_dir);
328  telemetry::phase_exit();
329  let outcome = match outcome {
330    Ok(Some(o)) => o,
331    Ok(None) => return Ok(None),
332    Err(e) => {
333      // Fail toward flagging: the whole-DOM fallback may well OOM on an input
334      // this large, but a loud failure beats a silently wrong result — and
335      // beats the whole-DOM fallback, whose parse of a threshold-sized file
336      // is a guaranteed OOM on commodity RAM (witnessed: a mid-stream error
337      // fell back into a 2.68 GB whole-DOM parse that grew past a 26 GB
338      // ceiling with zero pages to show). The unions this front-end cannot
339      // evaluate never reach here — the caller gates on `supports_union`.
340      post_error("split", &format!("streaming split failed: {e}"));
341      return Err(());
342    },
343  };
344  // The engagement marker the parity/threshold tests assert on.
345  Info!(
346    "post",
347    "stream-split",
348    s!(
349      "streaming split engaged for '{}': {} pages",
350      path,
351      outcome.pages.len()
352    )
353  );
354  note_progress(&format!("Split into {} documents", outcome.pages.len()));
355  let intent_literal = outcome
356    .latexml_pis
357    .iter()
358    .any(|pi| pi.contains("package=\"ar5iv"));
359  let svg_fragments = extract_svg_fragments(&outcome.picture_xml);
360
361  // Pre-order Scan sweep over the spilled pages — the streaming equivalent of
362  // `run_phase(docs, Scan)` + the driver's spill loop, ONE page resident at a
363  // time. The order is semantic, not cosmetic: SITE_ROOT is taken from the
364  // first page scanned (the root page), an ancestor must be in the DB before
365  // its descendants (Scan's parent inference falls back to SITE_ROOT
366  // otherwise), and each parent's `children` list follows scan order — all
367  // three feed CrossRef's navigation.
368  telemetry::phase_enter(Phase::PostScan);
369  let mut scanner = latexml_post::scan::Scan::new(ObjectDB::new());
370  let mut spilled: Vec<SpilledPage> = Vec::with_capacity(outcome.pages.len());
371  for page in &outcome.pages {
372    if let Err(e) = latexml_core::stomach::check_timeout() {
373      // FATAL, not Error: a scan stopped partway means the delivered site
374      // would be silently truncated (user decision 2026-08-02 — a resource
375      // stop that truncates the deliverable carries fatal severity).
376      Info!(
377        "post",
378        "stopped",
379        s!(
380          "Scan stopped after {} of {} pages",
381          spilled.len(),
382          outcome.pages.len()
383        )
384      );
385      e.log_fatal();
386      telemetry::phase_exit();
387      return Err(());
388    }
389    let path_str = page.path.to_string_lossy().into_owned();
390    let page_doc = match PostDocument::new_from_file(&path_str, page_opts.clone()) {
391      Ok(mut d) => {
392        d.destination = Some(page.destination.clone());
393        d.destination_directory = destination_directory_of(&page.destination);
394        d
395      },
396      Err(e) => {
397        post_error("post", &format!("cannot read a streamed page: {e}"));
398        telemetry::phase_exit();
399        return Err(());
400      },
401    };
402    // Scan's only page mutation: a root without an id gets
403    // `xml:id="Document"`. Detect it so the spill is refreshed (pass B
404    // re-parses pages from disk and must see what Scan registered).
405    let root_unnamed = page_doc
406      .get_document_element()
407      .and_then(|r| latexml_post::document::get_xml_id(&r))
408      .is_none();
409    let nodes = scanner.to_process(&page_doc);
410    let processed = if nodes.is_empty() {
411      vec![page_doc]
412    } else {
413      match scanner.process(page_doc, nodes) {
414        Ok(p) => p,
415        Err(e) => {
416          post_error("Scan", &format!("Scan failed: {e}"));
417          telemetry::phase_exit();
418          return Err(());
419        },
420      }
421    };
422    for d in processed {
423      if root_unnamed && let Err(e) = std::fs::write(&page.path, d.to_xml_string()) {
424        post_error("post", &format!("cannot refresh a disk-staged page: {e}"));
425        telemetry::phase_exit();
426        return Err(());
427      }
428      let (needs_index, needs_bib) = page_placeholder_probes(&d);
429      spilled.push(SpilledPage {
430        needs_index,
431        needs_bib,
432        path: page.path.clone(),
433        destination: Some(page.destination.clone()),
434        destination_directory: destination_directory_of(&page.destination),
435      });
436      // The page's DOM drops here — one resident at a time.
437    }
438  }
439  telemetry::phase_exit();
440  Ok(Some(PostFront {
441    spilled_pages: spilled,
442    db: scanner.db,
443    svg_fragments,
444    intent_literal,
445  }))
446}
447
448/// Write an oversized handoff to a temp file for the streaming file parser.
449/// Beside the destination when there is one (writing to the destination
450/// directory is always allowed), else the system temp dir.
451fn spill_oversized_xml(xml: &str, opts: &PostOptions) -> std::io::Result<std::path::PathBuf> {
452  let dir = opts
453    .destination
454    .and_then(|d| {
455      std::path::Path::new(d)
456        .parent()
457        .map(std::path::Path::to_path_buf)
458    })
459    .filter(|d| !d.as_os_str().is_empty())
460    .unwrap_or_else(std::env::temp_dir);
461  let path = dir.join(format!("latexml-post-handoff-{}.xml", std::process::id()));
462  std::fs::write(&path, xml)?;
463  Ok(path)
464}
465
466fn run_post_processing_impl(input: PostInput, opts: &PostOptions) -> String {
467  // Oversized in-memory handoff → spill + streaming file parse (see
468  // `post_mem_parse_limit`). The temp file is removed on every exit path by
469  // the guard; a spill failure falls through to the memory parse, whose own
470  // error reporting then names the real problem.
471  let mut _spill_cleanup: Option<std::path::PathBuf> = None;
472  let spilled_path: Option<String>;
473  // A handoff below the i32 ceiling but at/above the streaming-split
474  // threshold also spills when splitting is on: only the `File` arm can take
475  // the streaming split, and a memory parse of a multi-GB handoff is the OOM
476  // this work removes.
477  let spill_for_streaming = |len: usize| opts.split && len as u64 >= stream_split_threshold();
478  let input = match input {
479    PostInput::Xml(xml)
480      if xml.len() >= post_mem_parse_limit() || spill_for_streaming(xml.len()) =>
481    {
482      match spill_oversized_xml(xml, opts) {
483        Ok(path) => {
484          // Operationally worth a line: a multi-GB temp file just appeared
485          // beside the destination. Also the guard test's engagement proof —
486          // without it a silently-failing spill would fall back to the memory
487          // parse and the test would pass vacuously.
488          latexml_core::Info!(
489            "post",
490            "spill",
491            format!(
492              "oversized handoff ({} bytes) staged to {}",
493              xml.len(),
494              path.display()
495            )
496          );
497          spilled_path = Some(path.to_string_lossy().into_owned());
498          _spill_cleanup = Some(path);
499          PostInput::File(spilled_path.as_deref().expect("just set"))
500        },
501        Err(e) => {
502          latexml_core::Info!(
503            "post",
504            "spill",
505            format!("could not stage an oversized handoff to disk ({e}); parsing from memory")
506          );
507          PostInput::Xml(xml)
508        },
509      }
510    },
511    other => other,
512  };
513  let result = run_post_processing_inner(input, opts);
514  if let Some(path) = _spill_cleanup {
515    let _ = std::fs::remove_file(path);
516  }
517  result
518}
519
520fn run_post_processing_inner(input: PostInput, opts: &PostOptions) -> String {
521  let PostOptions {
522    pmml,
523    cmml,
524    keep_xmath,
525    stylesheet,
526    destination,
527    source_directory,
528    site_directory,
529    search_paths,
530    nodefaultresources,
531    css_files,
532    js_files,
533    noinvisibletimes,
534    plane1,
535    hackplane1,
536    mathtex,
537    navigationtoc,
538    schemadocs,
539    split,
540    ref split_xpath,
541    split_naming,
542    xslt_parameters,
543    graphics_svg_threshold_kb,
544    graphicimages,
545    timestamp,
546    icon,
547    whatsout,
548  } = *opts;
549
550  let mut doc_opts = PostDocumentOptions::default();
551  if let Some(dest) = destination {
552    doc_opts.destination = Some(dest.to_string());
553  }
554  if let Some(src_dir) = source_directory {
555    doc_opts.source_directory = Some(src_dir.to_string());
556    let mut sp = doc_opts.searchpaths.take().unwrap_or_default();
557    sp.push(src_dir.to_string());
558    doc_opts.searchpaths = Some(sp);
559  }
560  // --sitedirectory (Perl LaTeXML.pm L430 `siteDirectory`): the site root for
561  // relativizing cross-document/resource URLs. When unset, `PostDocument::new`
562  // falls back to the destination's directory (document.rs, Perl Config.pm L466).
563  if let Some(site_dir) = site_directory {
564    doc_opts.site_directory = Some(site_dir.to_string());
565  }
566  // `raw_xml` holds the in-memory serialization — `Some` only for `Xml` input.
567  // `File` input streams from disk and never holds it, so `raw_xml` is `None`
568  // and the three raw-string features (empty-doc substitution, SVG extraction,
569  // ar5iv sniff) plus the on-failure echo are re-derived from the parsed doc.
570  //
571  // Perl LaTeXML.pm L330-336: an empty core result (e.g. after a Fatal) is still
572  // post-processed against a bare <document/> root ("important for utility
573  // features such as packing .zip archives"). Mirror that for empty in-memory
574  // input; a File is never empty (an empty file simply errors on parse below).
575  let raw_xml: Option<&str> = match input {
576    PostInput::Xml(xml) if xml.trim().is_empty() => Some("<document/>"),
577    PostInput::Xml(xml) => Some(xml),
578    PostInput::File(_) => None,
579  };
580  // Best-effort output when a phase bails: echo the original XML for in-memory
581  // input; for File input the source is on disk (and a failure here is itself
582  // the case we are hardening against), so return empty and rely on the logged
583  // Error + status code.
584  let fallback = || raw_xml.map(str::to_string).unwrap_or_default();
585
586  // Pass B re-parses each spilled page and must see the SAME searchpaths and
587  // site directory as this parse does.
588  let page_opts = doc_opts.clone();
589
590  // The per-page spill directory, created up front: BOTH front-ends (the
591  // streaming split and the whole-DOM parse) write their pages into it.
592  // Beside the destination when there is one — the same volume the output
593  // lands on (and NOT the system temp dir, which can be a RAM-backed tmpfs:
594  // a 40k-page document spills the whole core XML's worth of pages here).
595  let mut spill_builder = tempfile::Builder::new();
596  spill_builder.prefix(".latexml-post-pages-");
597  let page_spill = match destination
598    .and_then(|d| std::path::Path::new(d).parent())
599    .filter(|p| !p.as_os_str().is_empty() && p.is_dir())
600  {
601    Some(dest_dir) => spill_builder.tempdir_in(dest_dir),
602    None => spill_builder.tempdir(),
603  };
604  let page_spill = match page_spill {
605    Ok(dir) => dir,
606    Err(e) => {
607      post_error(
608        "post",
609        &format!("cannot create the page staging directory: {e}"),
610      );
611      return fallback();
612    },
613  };
614
615  // ---- Front-end selection --------------------------------------------------
616  //
617  // For a *file* input that will be split and is past the streaming threshold
618  // (or force-enabled), the streaming split front-end produces the spilled
619  // pages + scanned ObjectDB without ever parsing the whole document — the
620  // whole-DOM parse of a 2.68 GB core XML needs ~30+ GB and OOM'd a 31 GB
621  // host before Split ran (laptop UAT 2026-07-31). Everything downstream of
622  // this block is shared between the two front-ends.
623  let streamed: Option<PostFront> = if let PostInput::File(path) = input
624    && split
625    && destination.is_some()
626    && let Some(xpath) = split_xpath.as_deref()
627    && latexml_post::stream_split::supports_union(xpath)
628    && stream_split_gate(path)
629  {
630    match try_streaming_front(
631      path,
632      xpath,
633      split_naming,
634      destination,
635      &page_opts,
636      page_spill.path(),
637    ) {
638      Ok(front) => front,
639      Err(()) => return fallback(),
640    }
641  } else {
642    None
643  };
644
645  let front = if let Some(front) = streamed {
646    front
647  } else {
648    // ==== Whole-DOM front-end (the pre-existing pipeline, indentation kept) ====
649    telemetry::phase_enter(Phase::PostXmlParse);
650    let t_parse = audit_start("PostDocument parse");
651    let parsed = match input {
652      // `raw_xml` is `Some` for `Xml` input (the two arms above), so the default
653      // is dead — kept only to avoid an `unwrap`.
654      PostInput::Xml(_) => {
655        PostDocument::new_from_string(raw_xml.unwrap_or("<document/>"), doc_opts)
656      },
657      PostInput::File(path) => PostDocument::new_from_file(path, doc_opts),
658    };
659    let doc = match parsed {
660      Ok(d) => d,
661      Err(e) => {
662        post_error("parse", &format!("failed to parse XML: {e}"));
663        telemetry::phase_exit();
664        return fallback();
665      },
666    };
667    audit_end(t_parse);
668    telemetry::phase_exit();
669
670    // SVG extraction reads the pre-post XML before any in-tree mutation; do it
671    // once up-front so the regex-based fragment table is valid for every split
672    // sub-document below. In-memory input scans the string; File input locates
673    // pictures via a limit-safe `//ltx:picture` DOM query and serializes only
674    // those (never the whole file) — most large documents have none.
675    let t_svg = audit_start("SVG extraction");
676    let svg_fragments = match raw_xml {
677      Some(xml) => extract_svg_fragments(xml),
678      None => extract_svg_fragments_from_doc(&doc),
679    };
680    audit_end(t_svg);
681
682    // ar5iv sniff: the ar5iv package emits literal-intent MathML. In-memory input
683    // checks the raw string; file input checks the parsed doc's `<?latexml
684    // package="ar5iv"?>` PI. Computed here — before Split moves `doc` into `docs`.
685    let intent_literal = match raw_xml {
686      Some(xml) => xml.contains("package=\"ar5iv"),
687      None => doc
688        .processing_instructions()
689        .iter()
690        .any(|pi| pi.contains("package=\"ar5iv")),
691    };
692
693    // Perl-faithful pipeline order (latexmlpost L223-242):
694    //   Split → Scan → MakeBibliography → CrossRef → Graphics → ...
695    // Split runs FIRST so each downstream pass sees the per-page
696    // destination. With the previous order (Scan before Split), every
697    // entry's `location` was the root document and CrossRef built every
698    // ref as a within-page anchor — the user-visible TOC links pointed
699    // at `#Ch1` instead of `Ch1.html`.
700    //
701    // Phase 1: Split (only attributes time when --split is on)
702    let mut docs: Vec<PostDocument> = if split {
703      telemetry::phase_enter(Phase::Split);
704      let result = if let Some(xpath) = split_xpath {
705        let naming = resolve_split_naming(split_naming);
706        let mut splitter = latexml_post::split::Split::new(xpath, naming, false);
707        let split_nodes = splitter.to_process(&doc);
708        match splitter.process(doc, split_nodes) {
709          Ok(docs) => {
710            if docs.len() > 1 {
711              note_progress(&format!("Split into {} documents", docs.len()));
712            }
713            Ok(docs)
714          },
715          Err(e) => {
716            post_error("split", &format!("Split failed: {e}"));
717            Err(())
718          },
719        }
720      } else {
721        Ok(vec![doc])
722      };
723      telemetry::phase_exit();
724      match result {
725        Ok(ds) => ds,
726        Err(()) => return fallback(),
727      }
728    } else {
729      vec![doc]
730    };
731
732    // Helper: run one processor across every doc in a Vec, mirroring
733    // Perl Post.pm:50-65's `foreach $proc { @newdocs = ... }` loop.
734    // Each processor's per-doc `process` may return multiple docs (only
735    // Split actually does, and we've already run Split above), so the
736    // inner result is flattened back into `docs`.
737    fn run_phase<P: Processor + ?Sized>(
738      docs: Vec<PostDocument>,
739      proc: &mut P,
740      label: &'static str,
741    ) -> Result<Vec<PostDocument>, ()> {
742      let mut out: Vec<PostDocument> = Vec::with_capacity(docs.len());
743      for d in docs {
744        // Cooperative memory check per document, so a phase that grows across
745        // many documents degrades gracefully instead of being hard-killed.
746        //
747        // MEASURED LIMIT, so nobody reads more into this than it delivers: on the
748        // 614 MB witness at `--max-memory 6000` the ceiling is breached BEFORE
749        // this loop is reached — the one-time parse + split DOM alone exceeds it,
750        // so the run still ends at the hard watchdog (exit 137, zero pages). No
751        // cooperative check inside the phases can catch that; bounding it is
752        // task #147 (streaming the split parse). What this DOES cover is the
753        // document whose growth happens across the phase itself.
754        if let Err(e) = latexml_core::stomach::check_timeout() {
755          // FATAL, not Error — see the render-loop stop below.
756          Info!("post", "stopped", s!("{label} stopped mid-phase"));
757          e.log_fatal();
758          return Err(());
759        }
760        let nodes = proc.to_process(&d);
761        if nodes.is_empty() {
762          out.push(d);
763          continue;
764        }
765        match proc.process(d, nodes) {
766          Ok(processed) => out.extend(processed),
767          Err(e) => {
768            post_error(label, &format!("{label} failed: {e}"));
769            return Err(());
770          },
771        }
772      }
773      Ok(out)
774    }
775
776    // Phase 2: Scan — runs on EACH sub-document so its entries register
777    // the per-page `location` and `pageid`. Single shared ObjectDB so
778    // the later CrossRef pass can resolve cross-doc refs.
779    let mut scanner = latexml_post::scan::Scan::new(ObjectDB::new());
780    telemetry::phase_enter(Phase::PostScan);
781    let t_scan = audit_start("Scan");
782    docs = match run_phase(docs, &mut scanner, "Scan") {
783      Ok(d) => d,
784      Err(()) => {
785        telemetry::phase_exit();
786        return fallback();
787      },
788    };
789    audit_end(t_scan);
790    telemetry::phase_exit();
791
792    // ---- Pass A / Pass B boundary -------------------------------------------
793    //
794    // Scan is the ONLY phase that needs global knowledge: it visits every page
795    // and produces strings in the ObjectDB. Everything after it is page-local.
796    // The driver used to be phase-major (`for each phase { for each page }`),
797    // which keeps every page alive at every boundary — and a live page is not
798    // cheap: each is its own `xmlDoc` with its own dictionary, id table and
799    // lazily-built caches, measured at ~1.6 MB of per-document overhead. Across
800    // the 40,201 pages of a 614 MB core XML that alone grew RSS from 16 GB to
801    // 80 GB *during Scan*, wrote zero pages, and was killed by the ceiling.
802    //
803    // So: spill each scanned page to disk (into the shared `page_spill` dir
804    // created before front-end selection), free it, and render page-by-page
805    // below. Peak becomes the one-time split DOM plus ONE page. Spilling (rather
806    // than re-deriving pages from the source) keeps Scan's per-page
807    // `location`/`pageid` registration byte-identical — those semantics were
808    // themselves the fix for empty cross-document TOCs, so they are the wrong
809    // thing to restructure here.
810    //
811    // (path, destination, destination_directory) — the metadata that does NOT
812    // survive an XML round-trip and must be restored in pass B.
813    // Plus a flag per page: does it carry an index/glossary/bibliography
814    // placeholder? Recorded HERE, while the page is already parsed, so the two
815    // baton sweeps below visit only the handful of pages that have work instead
816    // of re-parsing every page twice (~80 k redundant parses on a 40 k-page
817    // document).
818    let mut spilled_pages = Vec::with_capacity(docs.len());
819    for (i, d) in docs.drain(..).enumerate() {
820      let path = page_spill.path().join(format!("page-{i:07}.xml"));
821      if let Err(e) = std::fs::write(&path, d.to_xml_string()) {
822        post_error("post", &format!("cannot stage page {i} to disk: {e}"));
823        return fallback();
824      }
825      let (needs_index, needs_bib) = page_placeholder_probes(&d);
826      spilled_pages.push(SpilledPage {
827        needs_index,
828        needs_bib,
829        path,
830        destination: d.get_destination().map(String::from),
831        destination_directory: d.get_destination_directory().map(String::from),
832      });
833      // `d` drops here: one page's worth of DOM and caches released before the
834      // next is touched.
835    }
836    PostFront {
837      spilled_pages,
838      db: scanner.db,
839      svg_fragments,
840      intent_literal,
841    }
842  }; // ==== end of the whole-DOM front-end ====
843  let PostFront {
844    spilled_pages,
845    db,
846    svg_fragments,
847    intent_literal,
848  } = front;
849
850  // Phase 2b: MakeIndex (Perl LaTeXML.pm L466-470)
851  //   Runs BEFORE MakeBibliography in Perl. Populates `<ltx:indexlist>`
852  //   inside `<ltx:index>` placeholders and `<ltx:glossarylist>` inside
853  //   `<ltx:glossary>` placeholders, using the `GLOSSARY:*` / `INDEX:*`
854  //   entries Scan registered in the ObjectDB. Without this pass, glossary
855  //   sections render empty in HTML (witness: tests/structure/glossary.tex).
856  let mut indexer = latexml_post::make_index::MakeIndex::new(db, false, false);
857
858  // Phase 3: MakeBibliography
859  //
860  // A raw `.bib` is converted by a recursive BibTeX session (Perl
861  // `MakeBibliography.pm::convertBibliography`), which needs this crate's
862  // model loader — so `latexml_post` declares the hook and we fill it in here,
863  // on the thread that is about to run the pipeline.
864  crate::bib_session::install();
865
866  // Phase 4: CrossRef — built only once the bibliography sweep has handed the
867  // ObjectDB baton on (see `sweep_pages`), so nothing holds two owners of it.
868  let build_crossref = |db| {
869    let mut crossref =
870      latexml_post::crossref::CrossRef::new(db, latexml_post::crossref::UrlStyle::File, true);
871    if let Some(navtoc) = navigationtoc {
872      crossref.set_navigation_toc(navtoc);
873    }
874    crossref
875  };
876
877  // Phase 5: Graphics (Perl `graphicimages!` → `dographics`, default on).
878  // `--nographicimages` skips the phase wholesale, leaving the raw
879  // `<ltx:graphics>` references in the output untouched.
880  let graphics_proc = graphicimages.then(|| {
881    latexml_post::graphics::Graphics::new(None, true)
882      .with_svg_threshold_kb(graphics_svg_threshold_kb)
883  });
884
885  // Phase 3: MathML + XSLT
886  let post = latexml_post::Post::new();
887  let mut processors: Vec<Box<dyn Processor>> = Vec::new();
888
889  // `intent_literal` was computed up-front (before Split consumed `doc`).
890
891  // Parallel P+C markup: when both formats are requested, the Content-MathML
892  // processor is a *secondary* of the Presentation primary, folded into one
893  // `<m:semantics>`/`<m:annotation-xml>` by `combine_parallel` — NOT a second
894  // independent pass (which left the content tree as an orphan `<apply>` sibling
895  // of `<m:semantics>`, rendered as stray text by browsers). Mirrors Perl
896  // `MathProcessor`'s primary→secondary parallel model.
897  if pmml {
898    let mut presentation = latexml_post::mathml::MathML::new_presentation()
899      .with_keep_xmath(keep_xmath)
900      .with_invisible_times(!noinvisibletimes)
901      .with_plane1(plane1, hackplane1)
902      .with_mathtex(mathtex)
903      .with_intent_literal(intent_literal);
904    if cmml {
905      presentation = presentation.with_secondaries(vec![Box::new(
906        latexml_post::mathml::MathML::new_content()
907          .with_keep_xmath(keep_xmath)
908          .with_invisible_times(!noinvisibletimes)
909          .with_plane1(plane1, hackplane1)
910          .secondary(),
911      )]);
912    }
913    processors.push(Box::new(presentation));
914  } else if cmml {
915    // Content-only output (no presentation primary).
916    processors.push(Box::new(
917      latexml_post::mathml::MathML::new_content()
918        .with_keep_xmath(keep_xmath)
919        .with_invisible_times(!noinvisibletimes)
920        .with_plane1(plane1, hackplane1),
921    ));
922  }
923  // Clones of the fully-resolved XSLT inputs for the parallel-render worker
924  // manifest — `XSLT::new` consumes the real map/vec below, and by the time
925  // the pass-B driver decides between serial and parallel they are gone.
926  let mut manifest_xslt_params: Vec<(String, String)> = Vec::new();
927  let mut manifest_searchpaths: Vec<String> = Vec::new();
928  if let Some(xsl_path) = stylesheet {
929    let mut searchpaths = vec![".".to_string()];
930    if let Ok(exe) = std::env::current_exe()
931      && let Some(project_root) = exe
932        .parent()
933        .and_then(|p| p.parent())
934        .and_then(|p| p.parent())
935    {
936      searchpaths.insert(0, project_root.display().to_string());
937    }
938    // Prepend the user's `--path` directories so `--css`/`--javascript`
939    // resources are found there (and take priority over `.`/project root)
940    // when copy_param_resources searches for them.
941    for p in search_paths.iter().rev() {
942      searchpaths.insert(0, p.clone());
943    }
944    let mut xslt_params = rustc_hash::FxHashMap::default();
945
946    // When `--schemadocs` is on, auto-prepend the rustdoc-styled
947    // theme assets so callers don't need to repeat them on every
948    // invocation. Idempotent: skipped if the user has already
949    // listed the same basename via `--css` / `--javascript`.
950    let prepend = |list: &[String], extra: &str| -> Vec<String> {
951      if list
952        .iter()
953        .any(|p| std::path::Path::new(p).file_name().and_then(|s| s.to_str()) == Some(extra))
954      {
955        list.to_vec()
956      } else {
957        let mut out = Vec::with_capacity(list.len() + 1);
958        out.push(extra.to_string());
959        out.extend_from_slice(list);
960        out
961      }
962    };
963    let css_effective: Vec<String> = if schemadocs {
964      prepend(css_files, latexml_post::schema_docs::THEME_CSS_BASENAME)
965    } else {
966      css_files.to_vec()
967    };
968    let js_effective: Vec<String> = if schemadocs {
969      prepend(js_files, latexml_post::schema_docs::THEME_JS_BASENAME)
970    } else {
971      js_files.to_vec()
972    };
973    if !css_effective.is_empty() {
974      xslt_params.insert(
975        "CSS".to_string(),
976        format!("\"{}\"", css_effective.join("|")),
977      );
978    }
979    if !js_effective.is_empty() {
980      xslt_params.insert(
981        "JAVASCRIPT".to_string(),
982        format!("\"{}\"", js_effective.join("|")),
983      );
984    }
985    if let Some(navtoc) = navigationtoc {
986      xslt_params.insert("NAVIGATIONTOC".to_string(), format!("\"{}\"", navtoc));
987    }
988    if let Some(ts) = timestamp {
989      xslt_params.insert("TIMESTAMP".to_string(), format!("\"{}\"", ts));
990    }
991    if let Some(icon) = icon {
992      xslt_params.insert("ICON".to_string(), format!("\"{}\"", icon));
993    }
994    // Perl `LaTeXML.pm:562` unconditionally seeds `LATEXML_VERSION => '$LaTeXML::VERSION'`,
995    // which drives `LaTeXML-common.xsl`'s `LaTeXML_identifier` generator stamp
996    // (`<!--Generated by LaTeXML oxide (version X) ...-->`) and BookML's `utils.xsl`
997    // `b:version-leq($LATEXML_VERSION,…)`. We expose OUR own crate `X.Y.Z` (#320). Inserted
998    // BEFORE the user-override loop so `--xsltparameter LATEXML_VERSION=…` still wins (this
999    // is how Perl's test suite pins `version TEST` for version-independent goldens).
1000    xslt_params.insert(
1001      "LATEXML_VERSION".to_string(),
1002      format!("\"{}\"", crate::core_interface::LATEXML_VERSION),
1003    );
1004    for param in xslt_parameters {
1005      if let Some((key, value)) = param.split_once('=') {
1006        xslt_params.insert(key.to_string(), format!("\"{}\"", value));
1007      }
1008    }
1009    manifest_xslt_params = xslt_params
1010      .iter()
1011      .map(|(k, v)| (k.clone(), v.clone()))
1012      .collect();
1013    manifest_searchpaths = searchpaths.clone();
1014    match latexml_post::xslt::XSLT::new(
1015      xsl_path,
1016      xslt_params,
1017      nodefaultresources,
1018      None,
1019      searchpaths,
1020    ) {
1021      Ok(xslt) => processors.push(Box::new(xslt)),
1022      Err(e) => post_error("xslt", &format!("XSLT error: {e}")),
1023    }
1024  }
1025
1026  // process_chain attributes per-processor inside latexml_post::Post::
1027  // process_chain (MathmlPres / MathmlCont / Xslt). No outer phase wrap
1028  // needed; the inner per-processor guards cover their own time.
1029  //
1030  // Perl-faithful: pass ALL split docs to ProcessChain at once. Perl's
1031  // `Post::ProcessChain_internal` (Post.pm L41-67) seeds `@docs = ($doc)`
1032  // and lets each processor return a (possibly multi-element) list which
1033  // becomes the input to the next processor. We've already produced the
1034  // post-Split list above; ProcessChain just needs to fan MathML/XSLT
1035  // across it.
1036  let is_html_out = stylesheet.is_some_and(|s| s.contains("html"));
1037
1038  // ---- Pass B: render one page at a time, then FREE it --------------------
1039  //
1040  // Document-major, not phase-major: each page goes through MakeIndex ->
1041  // MakeBibliography -> CrossRef -> Graphics -> MathML -> XSLT -> write, and
1042  // is dropped before the next is parsed. Every one of these phases is
1043  // page-local; only Scan (pass A, above) needed the whole document, and it
1044  // left its results in the ObjectDB as strings. Peak is therefore ONE page
1045  // plus the index, whatever the page count.
1046  //
1047  // Failure semantics change deliberately: pages that finish are already on
1048  // disk, so a later failure leaves a partial site rather than nothing. That
1049  // matches how a resource Fatal already keeps partial core output.
1050  // (The per-page pipeline itself lives in `render_spilled_page`, shared with
1051  // the process-parallel worker in `crate::render_workers`.)
1052
1053  // The ObjectDB is a BATON: MakeIndex, MakeBibliography and CrossRef each take
1054  // it by value in turn, so they cannot be alive simultaneously. Each therefore
1055  // gets its own sweep over the spilled pages — still ONE page resident at a
1056  // time. A page is only re-spilled when its phase actually had work
1057  // (`to_process` non-empty), which for index/bibliography placeholders is a
1058  // handful of pages out of tens of thousands, so the sweeps cost a parse each
1059  // and almost no writes.
1060  fn sweep_pages(
1061    pages: &[SpilledPage],
1062    opts: &PostDocumentOptions,
1063    proc: &mut dyn Processor,
1064    label: &'static str,
1065    selector: fn(&SpilledPage) -> bool,
1066  ) -> Result<(), ()> {
1067    for page_meta in pages.iter().filter(|p| selector(p)) {
1068      let (path, dest, dest_dir) = (
1069        &page_meta.path,
1070        &page_meta.destination,
1071        &page_meta.destination_directory,
1072      );
1073      let path_str = path.to_string_lossy().into_owned();
1074      let mut page = PostDocument::new_from_file(&path_str, opts.clone()).map_err(|e| {
1075        post_error("post", &format!("cannot re-read a disk-staged page: {e}"));
1076      })?;
1077      page.destination = dest.clone();
1078      page.destination_directory = dest_dir.clone();
1079      let nodes = proc.to_process(&page);
1080      if nodes.is_empty() {
1081        continue; // untouched: the spill on disk is still current
1082      }
1083      let processed = proc.process(page, nodes).map_err(|e| {
1084        post_error(label, &format!("{label} failed: {e}"));
1085      })?;
1086      // A phase may split one page into several (an index page beside its
1087      // host); the extras are written by their own destination in pass B, so
1088      // re-spill each under the original path only for the first and append
1089      // the rest as additional spills is NOT needed here: MakeIndex and
1090      // MakeBibliography fill placeholders in place and return the same page.
1091      for d in processed.iter().take(1) {
1092        if let Err(e) = std::fs::write(path, d.to_xml_string()) {
1093          post_error("post", &format!("cannot re-stage a page to disk: {e}"));
1094          return Err(());
1095        }
1096      }
1097    }
1098    Ok(())
1099  }
1100
1101  if sweep_pages(&spilled_pages, &page_opts, &mut indexer, "MakeIndex", |p| {
1102    p.needs_index
1103  })
1104  .is_err()
1105  {
1106    return fallback();
1107  }
1108  let mut bibmaker = latexml_post::make_bibliography::MakeBibliography::new(indexer.db, false);
1109  telemetry::phase_enter(Phase::Bibliography);
1110  let t_bib = audit_start("MakeBibliography");
1111  let bib_result = sweep_pages(
1112    &spilled_pages,
1113    &page_opts,
1114    &mut bibmaker,
1115    "MakeBibliography",
1116    |p| p.needs_bib,
1117  );
1118  audit_end(t_bib);
1119  telemetry::phase_exit();
1120  if bib_result.is_err() {
1121    return fallback();
1122  }
1123  // ---- Parallel pass B: process-level page-range workers (design §6) ------
1124  //
1125  // Env-gated (`LATEXML_RENDER_JOBS`, default 1 = the serial path below,
1126  // unchanged), and only past a minimum page count — below it the db save +
1127  // child spawn overhead beats the win. The ObjectDB is COMPLETE here (Scan,
1128  // MakeIndex and MakeBibliography have all run), so it is saved once as a
1129  // SQLite file and each worker attaches it readonly; the workers run the
1130  // identical `render_spilled_page` pipeline over contiguous page ranges and
1131  // the parent folds their diagnostics deterministically, in chunk order.
1132  let render_jobs = crate::render_workers::render_jobs();
1133  if render_jobs > 1 && spilled_pages.len() >= crate::render_workers::MIN_PAGES_FOR_PARALLEL {
1134    let page_jobs: Vec<crate::render_workers::PageJob> = spilled_pages
1135      .iter()
1136      .map(|p| crate::render_workers::PageJob {
1137        path:                  p.path.clone(),
1138        destination:           p.destination.clone(),
1139        destination_directory: p.destination_directory.clone(),
1140      })
1141      .collect();
1142    let manifest = crate::render_workers::RenderManifest {
1143      // Filled in by `parallel_render` once the db is saved.
1144      dbfile: std::path::PathBuf::new(),
1145      navigation_toc: navigationtoc.map(String::from),
1146      graphicimages,
1147      graphics_svg_threshold_kb,
1148      pmml,
1149      cmml,
1150      keep_xmath,
1151      invisible_times: !noinvisibletimes,
1152      plane1,
1153      hackplane1,
1154      mathtex,
1155      intent_literal,
1156      stylesheet: stylesheet.map(String::from),
1157      xslt_params: manifest_xslt_params,
1158      nodefaultresources,
1159      searchpaths: manifest_searchpaths,
1160      is_html_out,
1161      svg_fragments: svg_fragments.clone(),
1162      schemadocs,
1163      whatsout: whatsout.as_cli().to_string(),
1164      page_opts: (&page_opts).into(),
1165      pages: Vec::new(),
1166    };
1167    let t_pages = audit_start("render_pages");
1168    let outcome = crate::render_workers::parallel_render(
1169      manifest,
1170      page_jobs,
1171      render_jobs,
1172      page_spill.path(),
1173      &bibmaker.db,
1174    );
1175    audit_end(t_pages);
1176    if let Some(result) = outcome {
1177      Info!(
1178        "post",
1179        "parallel-render",
1180        s!(
1181          "parallel render finished: {} of {} page(s) written",
1182          result.pages_rendered,
1183          spilled_pages.len()
1184        )
1185      );
1186      drop(page_spill);
1187      return result.main_output.unwrap_or_else(fallback);
1188    }
1189    // Setup failed BEFORE any worker spawned (already reported): fall through
1190    // to the serial render — the spilled pages are untouched on disk.
1191  }
1192
1193  let crossref = build_crossref(bibmaker.db);
1194  let ctx = PageRenderCtx {
1195    page_opts: page_opts.clone(),
1196    is_html_out,
1197    svg_fragments,
1198    schemadocs,
1199    whatsout,
1200  };
1201  let mut procs = PageProcessors {
1202    crossref,
1203    graphics: graphics_proc,
1204    post,
1205    processors,
1206  };
1207
1208  let mut main_output: Option<String> = None;
1209  let t_pages = audit_start("render_pages");
1210  let mut pages_rendered = 0usize;
1211  for SpilledPage {
1212    path,
1213    destination: dest,
1214    destination_directory: dest_dir,
1215    ..
1216  } in spilled_pages
1217  {
1218    // The cooperative memory guard, at the one seam post has: a page boundary.
1219    //
1220    // Nothing in latexml_post or this driver consulted it — `check_timeout`
1221    // appeared ZERO times across both — so an oversized post run was killed by
1222    // the hard watchdog at 100% of the ceiling (exit 137, `Fatal:oom:rss …
1223    // exceeded the … ceiling`) with nothing on disk, where the design promise
1224    // is a named Fatal at 75% plus whatever finished. Measured on the 131 MB
1225    // witness at `--max-memory 48000`: post died at 48,002 MiB having written
1226    // ZERO pages. Same shape as the Build phase before it got a guard.
1227    //
1228    // Stopping HERE is cheap and honest: pages already rendered are already
1229    // written, so the run ends with a real, if partial, site.
1230    if let Err(e) = latexml_core::stomach::check_timeout() {
1231      // FATAL, not Error (user decision 2026-08-02): a render stopped at
1232      // page N of M is a TRUNCATED deliverable — the same severity class as
1233      // the core's cheap-partial handoff. The pages already written stay on
1234      // disk (a real, if partial, site), the verdict says failed, and the
1235      // combined status the framework reads is 3. Witness that motivated
1236      // it: the joint 131 MB run stopped at 56,088 of 115,519 pages with
1237      // Error-class severity and process exit 0.
1238      Info!(
1239        "post",
1240        "stopped",
1241        s!("render stopped after {pages_rendered} page(s); partial site preserved")
1242      );
1243      e.log_fatal();
1244      break;
1245    }
1246    // Hand freed page memory back to the OS, exactly as core pass 1 does
1247    // (core_interface.rs, same rationale): every page cycles a full DOM +
1248    // XSLT result through GLIBC's heap (libxml2/libxslt allocate via libc,
1249    // not the Rust allocator), and glibc keeps freed mid-heap pages mapped —
1250    // the render loop's measured ~152 KB/page RSS growth on the 131 MB
1251    // witness is dominated by exactly this class, and it is what pushed the
1252    // single-invocation run over the memory fuse at page 56,088 of 115,519.
1253    // Rate-limited: a trim walks the heap.
1254    if pages_rendered > 0 && pages_rendered.is_multiple_of(512) {
1255      #[cfg(target_os = "linux")]
1256      unsafe {
1257        libc::malloc_trim(0);
1258      }
1259      #[cfg(not(feature = "dhat-heap"))]
1260      unsafe {
1261        libmimalloc_sys::mi_collect(true);
1262      }
1263    }
1264    // Retention triage probe (`LATEXML_POST_MEMDIAG=1`): splits the render
1265    // loop's RSS growth into live-C (libxml2/libxslt allocations still
1266    // reachable), free-but-mapped C, and "everything else" (Rust/mimalloc +
1267    // mmap). One run with this told the ~144 KB/page term apart from the
1268    // three candidates the first fixes addressed.
1269    #[cfg(target_os = "linux")]
1270    if pages_rendered > 0
1271      && pages_rendered.is_multiple_of(1024)
1272      && std::env::var("LATEXML_POST_MEMDIAG").is_ok()
1273    {
1274      let mi = unsafe { libc::mallinfo2() };
1275      let rss_mb = latexml_core::watchdog::process_rss_kb().unwrap_or(0) / 1024;
1276      eprintln!(
1277        "memdiag: pages={} RSS={}MB C-live={}MB C-free={}MB",
1278        pages_rendered,
1279        rss_mb,
1280        mi.uordblks / (1024 * 1024),
1281        mi.fordblks / (1024 * 1024)
1282      );
1283    }
1284    let outputs = match render_spilled_page(&path, &mut procs, &ctx, dest, dest_dir) {
1285      Ok(o) => o,
1286      Err(()) => return fallback(),
1287    };
1288    for (dest, output) in outputs {
1289      if let Some(path) = dest.as_deref() {
1290        if let Some(parent) = std::path::Path::new(path).parent()
1291          && !parent.as_os_str().is_empty()
1292        {
1293          let _ = std::fs::create_dir_all(parent);
1294        }
1295        pages_rendered += 1;
1296        if let Err(e) = std::fs::write(path, &output) {
1297          post_error("write", &format!("failed to write page {path}: {e}"));
1298        }
1299      }
1300      if main_output.is_none() {
1301        main_output = Some(output);
1302      }
1303    }
1304  }
1305  audit_end(t_pages);
1306  drop(page_spill);
1307
1308  main_output.unwrap_or_else(fallback)
1309}
1310
1311/// The per-page processor set for pass B — everything [`render_spilled_page`]
1312/// mutates. Bundled so the serial driver and the process-parallel worker
1313/// ([`crate::render_workers`]) share ONE pipeline implementation and cannot
1314/// drift.
1315pub(crate) struct PageProcessors {
1316  pub(crate) crossref:   latexml_post::crossref::CrossRef,
1317  pub(crate) graphics:   Option<latexml_post::graphics::Graphics>,
1318  pub(crate) post:       latexml_post::Post,
1319  pub(crate) processors: Vec<Box<dyn Processor>>,
1320}
1321
1322/// The read-only per-run context for pass B page rendering, shared by every
1323/// page of one conversion (serial driver or one parallel worker).
1324pub(crate) struct PageRenderCtx {
1325  /// Clone-source for each page's `PostDocument` parse — carries the same
1326  /// searchpaths/site-directory the pass-A parse used.
1327  pub(crate) page_opts:     PostDocumentOptions,
1328  pub(crate) is_html_out:   bool,
1329  pub(crate) svg_fragments: Vec<(String, String)>,
1330  pub(crate) schemadocs:    bool,
1331  pub(crate) whatsout:      latexml_post::extract::Whatsout,
1332}
1333
1334/// Run one page-local phase (CrossRef / Graphics) over one page, mirroring
1335/// `run_phase` for a single document. Failures are reported via [`post_error`].
1336fn run_page_phase(
1337  doc: PostDocument,
1338  proc: &mut dyn Processor,
1339  label: &'static str,
1340) -> Result<Vec<PostDocument>, ()> {
1341  let nodes = proc.to_process(&doc);
1342  if nodes.is_empty() {
1343    return Ok(vec![doc]);
1344  }
1345  proc.process(doc, nodes).map_err(|e| {
1346    post_error(label, &format!("{label} failed: {e}"));
1347  })
1348}
1349
1350/// The pass-B per-page pipeline: parse one spilled page (deleting the spill
1351/// file as soon as it is parsed), run CrossRef → Graphics → MathML/XSLT, and
1352/// return the finalized `(destination, output)` pairs for the caller to write.
1353/// Exactly the body the serial render loop ran in place before the
1354/// process-parallel split — phase order, telemetry and audit calls included —
1355/// so the serial path stays byte-identical (guard:
1356/// `118_streaming_split_parity`). `Err(())` means the failure was already
1357/// reported via [`post_error`] (or a phase's own diagnostics).
1358pub(crate) fn render_spilled_page(
1359  path: &std::path::Path,
1360  procs: &mut PageProcessors,
1361  ctx: &PageRenderCtx,
1362  destination: Option<String>,
1363  destination_directory: Option<String>,
1364) -> Result<Vec<(Option<String>, String)>, ()> {
1365  let path_str = path.to_string_lossy().into_owned();
1366  let page = match PostDocument::new_from_file(&path_str, ctx.page_opts.clone()) {
1367    Ok(mut d) => {
1368      d.destination = destination;
1369      d.destination_directory = destination_directory;
1370      d
1371    },
1372    Err(e) => {
1373      post_error("post", &format!("cannot re-read a disk-staged page: {e}"));
1374      return Err(());
1375    },
1376  };
1377  // Free the spill as soon as it is parsed: for a 40k-page document the
1378  // spill area is the size of the core XML, and it need not outlive its page.
1379  let _ = std::fs::remove_file(path);
1380
1381  // CrossRef resolves this page's references out of the completed ObjectDB,
1382  // and its navigation/TOC work reads the DB rather than sibling pages — so
1383  // it is page-local once pass A has finished.
1384  telemetry::phase_enter(Phase::Crossref);
1385  let t_xref = audit_start("CrossRef");
1386  let xref = run_page_phase(page, &mut procs.crossref, "CrossRef");
1387  audit_end(t_xref);
1388  telemetry::phase_exit();
1389  let mut staged = xref?;
1390  if let Some(gfx) = procs.graphics.as_mut() {
1391    telemetry::phase_enter(Phase::Graphics);
1392    let t_gfx = audit_start("Graphics");
1393    let mut next = Vec::with_capacity(staged.len());
1394    let mut failed = false;
1395    for d in staged.drain(..) {
1396      match run_page_phase(d, gfx, "Graphics") {
1397        Ok(out) => next.extend(out),
1398        Err(()) => {
1399          failed = true;
1400          break;
1401        },
1402      }
1403    }
1404    audit_end(t_gfx);
1405    telemetry::phase_exit();
1406    if failed {
1407      return Err(());
1408    }
1409    staged = next;
1410  }
1411
1412  let rendered = match procs.post.process_chain(staged, &mut procs.processors) {
1413    Ok(r) => r,
1414    Err(e) => {
1415      post_error("convert", &format!("Post-processing failed: {e}"));
1416      return Err(());
1417    },
1418  };
1419
1420  let mut outputs = Vec::with_capacity(rendered.len());
1421  for doc in rendered {
1422    let dest = doc.get_destination().map(String::from);
1423    let output = latexml_post::extract::serialize_whatsout(&doc, ctx.whatsout);
1424    let output = if ctx.is_html_out {
1425      finalize_html5(output, &ctx.svg_fragments)
1426    } else {
1427      output
1428    };
1429    let output = if ctx.schemadocs && ctx.is_html_out {
1430      latexml_post::schema_docs::process_page(&output)
1431    } else {
1432      output
1433    };
1434    outputs.push((dest, output));
1435    // `doc` drops here — the whole point.
1436  }
1437  Ok(outputs)
1438}
1439
1440/// One page spilled between the post pipeline's two passes: where its XML
1441/// lives, the metadata that does NOT survive an XML round-trip, and whether it
1442/// carries the placeholders the two ObjectDB-baton sweeps look for (recorded in
1443/// pass A, while the page is already parsed).
1444struct SpilledPage {
1445  path:                  std::path::PathBuf,
1446  destination:           Option<String>,
1447  destination_directory: Option<String>,
1448  needs_index:           bool,
1449  needs_bib:             bool,
1450}
1451
1452/// Re-derive SVG fragments from a parsed document (the file-input equivalent of
1453/// [`extract_svg_fragments`]).
1454///
1455/// `//ltx:picture` is a *typed* descendant query, which libxml2 evaluates
1456/// without materializing `descendant-or-self::node()` — so it stays under the
1457/// 10M node-set ceiling even on a 600 MB document. Most large documents have no
1458/// pictures at all (the fast, zero-serialization path); when they do, only the
1459/// picture subtrees are serialized and handed to the shared string extractor,
1460/// never the whole file.
1461fn extract_svg_fragments_from_doc(doc: &PostDocument) -> Vec<(String, String)> {
1462  let pictures = doc.findnodes("//ltx:picture");
1463  if pictures.is_empty() {
1464    return Vec::new();
1465  }
1466  let joined: String = pictures.iter().map(|p| doc.node_to_string(p)).collect();
1467  extract_svg_fragments(&joined)
1468}
1469
1470/// Apply HTML5 cleanup (XML prolog strip, void-element fixes) and inject SVG
1471/// fragments into empty `ltx_picture` spans. Pulled out of `run_post_processing`
1472/// so it can run on every split sub-document, not just the first.
1473fn finalize_html5(output: String, svg_fragments: &[(String, String)]) -> String {
1474  use std::sync::LazyLock;
1475  // Cached at first call — regex compile is the slow part of `Regex::new`,
1476  // and finalize_html5 runs on every (sub-)document in the post-pipeline.
1477  static XML_PROLOG_RE: LazyLock<regex::Regex> =
1478    LazyLock::new(|| regex::Regex::new(r"^<\?xml[^?]*\?>\s*").unwrap());
1479  static NON_VOID_SELF_CLOSE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
1480    regex::Regex::new(
1481      r"<(span|div|p|a|td|th|tr|section|article|figure|figcaption|pre|code|em|strong|b|i|u|sub|sup|small|cite)(\s[^>]*)?/>",
1482    ).unwrap()
1483  });
1484  static VOID_CLOSE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
1485    regex::Regex::new(r"</(br|img|hr|input|meta|link|col|area|base|source|track|wbr|embed|param)>")
1486      .unwrap()
1487  });
1488  static VOID_SELF_CLOSE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
1489    regex::Regex::new(
1490      r"<(br|img|hr|input|meta|link|col|area|base|source|track|wbr|embed|param)(\s[^>]*?)\s*/>",
1491    )
1492    .unwrap()
1493  });
1494
1495  let _gp_html5 = telemetry::phase(Phase::Html5Fixups);
1496  // Strip <?xml version...?> prolog: HTML5 must NOT have an XML declaration.
1497  // libxml2's to_string() includes it by default; we strip it here.
1498  let output = XML_PROLOG_RE.replace(&output, "").to_string();
1499  let output = NON_VOID_SELF_CLOSE_RE
1500    .replace_all(&output, "<$1$2></$1>")
1501    .to_string();
1502  let output = VOID_CLOSE_RE.replace_all(&output, "").to_string();
1503  let mut output = VOID_SELF_CLOSE_RE
1504    .replace_all(&output, "<$1$2>")
1505    .to_string();
1506  // SVG fragment injection into the empty `ltx_picture` placeholder spans.
1507  //
1508  // WHY this is a string splice and not a DOM operation (issue #398).
1509  // The SVG must land AFTER the XSLT stage (it is collected separately by
1510  // `extract_svg_fragments` / the streaming split), and the obvious DOM
1511  // alternative — re-parse the SVG fragment and append it under the placeholder
1512  // node before serialization — was deliberately abandoned: it tripped a
1513  // libxml2 use-after-free in `PostDocument` cleanup (see the note on
1514  // `extract_svg_fragments`). So we splice into the serialized string instead.
1515  //
1516  // The original splice hard-coded the placeholder as `<span id="…"
1517  // class="ltx_picture"…>` — coupled to id-before-class order AND double quotes,
1518  // neither guaranteed by a serializer. Measured (issue #398): on every LIVE
1519  // input the coupling holds — the XSLT emits `id` (add_id) then `class`
1520  // (add_classes is the first thing add_attributes does), and libxml2 preserves
1521  // insertion order and always double-quotes — so no real break was
1522  // demonstrable; the fragility is latent. We keep the string splice (the DOM
1523  // refactor is not worth re-entering the use-after-free for a latent concern)
1524  // but make the MATCH robust so a future serializer/XSLT change cannot silently
1525  // break it: match any empty `<span>` whose class contains `ltx_picture`,
1526  // regardless of attribute order or quote style, and look the fragment up by
1527  // the id found inside.
1528  //
1529  // WHEN TO REVISIT (do the real DOM refactor and delete this splice):
1530  //   * once the fork's `PostDocument` cleanup no longer use-after-frees on an
1531  //     inserted subtree (rust-libxml; we have fixed adjacent UAF/NULL-deref
1532  //     bugs there this year) — then inject the SVG as child nodes of the
1533  //     placeholder in the post-XSLT DOM (`doc`, before `serialize_whatsout`),
1534  //     which also lets the void-element normalization above move to a proper
1535  //     HTML serializer;
1536  //   * or if a demonstrated attribute-order/quote break ever appears (none
1537  //     today) — that would raise the priority from latent to real.
1538  if !svg_fragments.is_empty() {
1539    static EMPTY_PICTURE_SPAN: LazyLock<regex::Regex> = LazyLock::new(|| {
1540      // An empty `<span …></span>` carrying `ltx_picture` as a whole class
1541      // token, in a single- or double-quoted class value, with the other
1542      // attributes (notably `id`) in ANY position.
1543      regex::Regex::new(
1544        r#"<span\b(?P<attrs>[^>]*\bclass\s*=\s*(?:"[^"]*\bltx_picture\b[^"]*"|'[^']*\bltx_picture\b[^']*')[^>]*)></span>"#,
1545      )
1546      .unwrap()
1547    });
1548    static SPAN_ID: LazyLock<regex::Regex> =
1549      LazyLock::new(|| regex::Regex::new(r#"\bid\s*=\s*(?:"([^"]+)"|'([^']+)')"#).unwrap());
1550    output = EMPTY_PICTURE_SPAN
1551      .replace_all(&output, |caps: &regex::Captures| {
1552        let attrs = &caps["attrs"];
1553        let id = SPAN_ID
1554          .captures(attrs)
1555          .and_then(|c| c.get(1).or_else(|| c.get(2)))
1556          .map(|m| m.as_str());
1557        match id.and_then(|id| svg_fragments.iter().find(|(fid, _)| fid == id)) {
1558          // Preserve the placeholder's own attributes verbatim; only fill it.
1559          Some((_, svg_html)) => format!("<span{attrs}>{svg_html}</span>"),
1560          // A picture span we have no fragment for — leave it untouched.
1561          None => caps[0].to_string(),
1562        }
1563      })
1564      .to_string();
1565  }
1566  output
1567}
1568
1569/// Extract SVG fragments from intermediate LaTeXML XML.
1570///
1571/// Finds `<picture>` elements, converts their children to inline SVG HTML.
1572/// Uses a lightweight regex+string approach (no libxml2) to avoid the
1573/// use-after-free crash in PostDocument cleanup.
1574///
1575/// Returns (picture_id, svg_html) pairs for post-XSLT injection.
1576fn extract_svg_fragments(xml: &str) -> Vec<(String, String)> {
1577  use std::sync::LazyLock;
1578  // Fast-fail: most documents have no `<picture>` elements (tikz / pgf
1579  // is uncommon in the canvas). Skip the backtracking lazy-match
1580  // regex (`(?s)...(.*?)`) when `<picture` doesn't appear as a literal
1581  // substring. `str::contains` is a SIMD-accelerated byte search and
1582  // takes microseconds even on ~MB inputs.
1583  if !xml.contains("<picture") {
1584    return Vec::new();
1585  }
1586  static PICTURE_RE: LazyLock<regex::Regex> =
1587    LazyLock::new(|| regex::Regex::new(r#"(?s)<picture([^>]*)>(.*?)</picture>"#).unwrap());
1588  static ID_RE: LazyLock<regex::Regex> =
1589    LazyLock::new(|| regex::Regex::new(r#"xml:id="([^"]+)""#).unwrap());
1590  static WIDTH_RE: LazyLock<regex::Regex> =
1591    LazyLock::new(|| regex::Regex::new(r#"width="([^"]+)""#).unwrap());
1592  static HEIGHT_RE: LazyLock<regex::Regex> =
1593    LazyLock::new(|| regex::Regex::new(r#"height="([^"]+)""#).unwrap());
1594  let mut fragments = Vec::new();
1595  let picture_re = &*PICTURE_RE;
1596  let id_re = &*ID_RE;
1597  let width_re = &*WIDTH_RE;
1598  let height_re = &*HEIGHT_RE;
1599
1600  for pic_caps in picture_re.captures_iter(xml) {
1601    let attrs = &pic_caps[1];
1602    let content = &pic_caps[2];
1603    let id = id_re
1604      .captures(attrs)
1605      .map(|c| c[1].to_string())
1606      .unwrap_or_default();
1607    let width = width_re.captures(attrs).and_then(|c| parse_tex_dim(&c[1]));
1608    let height = height_re.captures(attrs).and_then(|c| parse_tex_dim(&c[1]));
1609
1610    if id.is_empty() || content.trim().is_empty() {
1611      continue;
1612    }
1613
1614    let w = width.unwrap_or(100.0);
1615    let h = height.unwrap_or(100.0);
1616
1617    // Build inline SVG: coordinate system has y-flip (TeX origin bottom-left, SVG top-left)
1618    let mut svg_content = format!(
1619      r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{w:.2}" height="{h:.2}" overflow="visible">"#,
1620    );
1621    svg_content.push_str(&format!(
1622      r#"<g transform="translate(0,{h:.2}) scale(1,-1)">"#,
1623    ));
1624
1625    // Convert ltx picture children to SVG elements
1626    svg_content.push_str(&convert_picture_children_to_svg(content));
1627
1628    svg_content.push_str("</g></svg>");
1629    fragments.push((id, svg_content));
1630  }
1631  fragments
1632}
1633
1634/// Convert LaTeXML picture children (g, line, text, circle, etc.) to SVG elements.
1635fn convert_picture_children_to_svg(content: &str) -> String {
1636  use std::sync::LazyLock;
1637  static G_RE: LazyLock<regex::Regex> =
1638    LazyLock::new(|| regex::Regex::new(r#"(?s)<g([^>]*)>(.*?)</g>"#).unwrap());
1639  static TRANSFORM_RE: LazyLock<regex::Regex> =
1640    LazyLock::new(|| regex::Regex::new(r#"transform="([^"]+)""#).unwrap());
1641  static LINE_RE: LazyLock<regex::Regex> =
1642    LazyLock::new(|| regex::Regex::new(r#"<line\s+points="([^"]+)"([^/]*)/?>"#).unwrap());
1643  static CIRCLE_RE: LazyLock<regex::Regex> =
1644    LazyLock::new(|| regex::Regex::new(r#"<circle([^/]*)/?>"#).unwrap());
1645  static ELLIPSE_RE: LazyLock<regex::Regex> =
1646    LazyLock::new(|| regex::Regex::new(r#"<ellipse([^/]*)/?>"#).unwrap());
1647  static RECT_RE: LazyLock<regex::Regex> =
1648    LazyLock::new(|| regex::Regex::new(r#"<rect([^/]*)/?>"#).unwrap());
1649  static POLYGON_RE: LazyLock<regex::Regex> =
1650    LazyLock::new(|| regex::Regex::new(r#"<polygon([^/]*)/?>"#).unwrap());
1651  static PATH_RE: LazyLock<regex::Regex> =
1652    LazyLock::new(|| regex::Regex::new(r#"<path([^/]*)/?>"#).unwrap());
1653  static BEZIER_RE: LazyLock<regex::Regex> =
1654    LazyLock::new(|| regex::Regex::new(r#"<bezier\s+points="([^"]+)"([^/]*)/?>"#).unwrap());
1655  static TEXT_RE: LazyLock<regex::Regex> =
1656    LazyLock::new(|| regex::Regex::new(r#"(?s)<text([^>]*)>(.*?)</text>"#).unwrap());
1657  // Foreign (non-SVG) content that a picture makebox can carry: a math label or
1658  // an embedded graphic. Perl's SVG.pm wraps these in <foreignObject>.
1659  static MATH_RE: LazyLock<regex::Regex> =
1660    LazyLock::new(|| regex::Regex::new(r#"(?s)<Math\b[^>]*>.*?</Math>"#).unwrap());
1661  static GRAPHICS_RE: LazyLock<regex::Regex> =
1662    LazyLock::new(|| regex::Regex::new(r#"<graphics\b[^>]*/>"#).unwrap());
1663
1664  let mut svg = String::new();
1665
1666  for g_caps in G_RE.captures_iter(content) {
1667    let g_attrs = &g_caps[1];
1668    let g_content = &g_caps[2];
1669
1670    // Extract transform
1671    let transform = TRANSFORM_RE.captures(g_attrs).map(|c| c[1].to_string());
1672
1673    if let Some(t) = &transform {
1674      svg.push_str(&format!(r#"<g transform="{t}">"#));
1675    } else {
1676      svg.push_str("<g>");
1677    }
1678
1679    // <line points="x1,y1 x2,y2" stroke="..." stroke-width="..."/>
1680    for line_caps in LINE_RE.captures_iter(g_content) {
1681      let points = &line_caps[1];
1682      let rest_attrs = &line_caps[2];
1683      let coords: Vec<&str> = points.split_whitespace().collect();
1684      if coords.len() >= 2 {
1685        let p1: Vec<&str> = coords[0].split(',').collect();
1686        let p2: Vec<&str> = coords[1].split(',').collect();
1687        if p1.len() == 2 && p2.len() == 2 {
1688          svg.push_str(&format!(
1689            r#"<line x1="{}" y1="{}" x2="{}" y2="{}"{}/>"#,
1690            p1[0], p1[1], p2[0], p2[1], rest_attrs
1691          ));
1692        }
1693      }
1694    }
1695
1696    // <circle cx="..." cy="..." r="..." .../>
1697    for circle_caps in CIRCLE_RE.captures_iter(g_content) {
1698      svg.push_str(&format!("<circle{}/>", &circle_caps[1]));
1699    }
1700
1701    // <ellipse cx="..." cy="..." rx="..." ry="..." .../>
1702    for ellipse_caps in ELLIPSE_RE.captures_iter(g_content) {
1703      svg.push_str(&format!("<ellipse{}/>", &ellipse_caps[1]));
1704    }
1705
1706    // <rect x="..." y="..." width="..." height="..." .../>
1707    for rect_caps in RECT_RE.captures_iter(g_content) {
1708      svg.push_str(&format!("<rect{}/>", &rect_caps[1]));
1709    }
1710
1711    // <polygon points="..." .../>
1712    for polygon_caps in POLYGON_RE.captures_iter(g_content) {
1713      svg.push_str(&format!("<polygon{}/>", &polygon_caps[1]));
1714    }
1715
1716    // <path d="..." .../>
1717    for path_caps in PATH_RE.captures_iter(g_content) {
1718      svg.push_str(&format!("<path{}/>", &path_caps[1]));
1719    }
1720
1721    // <bezier points="x1,y1 x2,y2 x3,y3 x4,y4" .../>
1722    // Convert to SVG cubic bezier path
1723    for bez_caps in BEZIER_RE.captures_iter(g_content) {
1724      let points = &bez_caps[1];
1725      let rest = &bez_caps[2];
1726      let coords: Vec<&str> = points.split_whitespace().collect();
1727      if coords.len() >= 4 {
1728        // SVG cubic bezier: M x0,y0 C x1,y1 x2,y2 x3,y3
1729        let d = format!(
1730          "M {} C {} {} {}",
1731          coords[0], coords[1], coords[2], coords[3]
1732        );
1733        svg.push_str(&format!(r#"<path d="{d}"{rest} fill="none"/>"#));
1734      } else if coords.len() >= 3 {
1735        // Quadratic bezier: M x0,y0 Q x1,y1 x2,y2
1736        let d = format!("M {} Q {} {}", coords[0], coords[1], coords[2]);
1737        svg.push_str(&format!(r#"<path d="{d}"{rest} fill="none"/>"#));
1738      }
1739    }
1740
1741    // <arc .../> — arc segments (rarely used, stub for now)
1742    // <wedge .../> — filled wedges (rarely used, stub for now)
1743
1744    // <text>...</text> — wrap in SVG text with y-flip correction
1745    for text_caps in TEXT_RE.captures_iter(g_content) {
1746      let text_attrs = &text_caps[1];
1747      let text_content = &text_caps[2];
1748      svg.push_str(&format!(
1749        r#"<g transform="scale(1,-1)"><text{text_attrs}>{text_content}</text></g>"#,
1750      ));
1751    }
1752
1753    // Foreign content (a `<Math>` label or a `<graphics>` image inside a picture
1754    // makebox) must survive into the SVG wrapped in a `<foreignObject>`, not be
1755    // dropped. Mirrors `SVG.pm::convertNode`
1756    // (LaTeXML/lib/LaTeXML/Post/SVG.pm:148-183): a non-SVG child takes its size
1757    // from its own width/imagewidth, else the containing `<g>`'s
1758    // innerwidth/width (defaults 1pt, non-zero required), and is placed in a
1759    // y-flipped `<g><foreignObject overflow="visible">`. html_feedback#74
1760    // (arXiv:0810.1673v3 Fig 2 — xfig math labels + `\epsfig` image).
1761    //
1762    // NOTE: `latexml_post::svg::SVG::convert_foreign` is the *canonical*,
1763    // DOM-based faithful port (it already handles this). This string-level
1764    // reimplementation exists only because the active SVG path splices serialized
1765    // strings post-XSLT to dodge a libxml2 PostDocument-cleanup UAF (see the
1766    // `finalize_html5` splice note). When that path can carry a live DOM, this
1767    // block and its siblings above are deleted in favor of the `svg.rs` Processor.
1768    for node in MATH_RE
1769      .find_iter(g_content)
1770      .chain(GRAPHICS_RE.find_iter(g_content))
1771      .map(|m| m.as_str())
1772    {
1773      let width = svg_attr(node, "width")
1774        .or_else(|| svg_attr(node, "imagewidth"))
1775        .or_else(|| svg_attr(g_attrs, "innerwidth"))
1776        .or_else(|| svg_attr(g_attrs, "width"))
1777        .unwrap_or_else(|| "1pt".to_string());
1778      let height = svg_attr(node, "height")
1779        .or_else(|| svg_attr(node, "imageheight"))
1780        .or_else(|| svg_attr(g_attrs, "innerheight"))
1781        .or_else(|| svg_attr(g_attrs, "height"))
1782        .unwrap_or_else(|| "1pt".to_string());
1783      let depth = svg_attr(node, "depth")
1784        .or_else(|| svg_attr(g_attrs, "innerdepth"))
1785        .or_else(|| svg_attr(g_attrs, "depth"))
1786        .unwrap_or_else(|| "0pt".to_string());
1787      let px_w = parse_tex_dim(&width).unwrap_or(1.0);
1788      let px_h = parse_tex_dim(&height).unwrap_or(1.0);
1789      let px_d = parse_tex_dim(&depth).unwrap_or(0.0);
1790      // Perl: y = to_px(height) + to_px(depth); flip so foreign content is upright.
1791      let y = px_h + px_d;
1792      svg.push_str(&format!(
1793        r#"<g transform="translate(0,{y:.2}) scale(1,-1)"><foreignObject width="{px_w:.2}" height="{px_h:.2}" overflow="visible">{node}</foreignObject></g>"#
1794      ));
1795    }
1796
1797    svg.push_str("</g>");
1798  }
1799
1800  // Also handle direct children not inside <g> (e.g. top-level <bezier>, <line>)
1801  // These appear directly inside <picture> without a <g> wrapper
1802  let direct_bezier_re =
1803    regex::Regex::new(r#"(?m)^\s*<bezier\s+points="([^"]+)"([^/]*)/?>"#).unwrap();
1804  for bez_caps in direct_bezier_re.captures_iter(content) {
1805    let points = &bez_caps[1];
1806    let rest = &bez_caps[2];
1807    let coords: Vec<&str> = points.split_whitespace().collect();
1808    if coords.len() >= 4 {
1809      let d = format!(
1810        "M {} C {} {} {}",
1811        coords[0], coords[1], coords[2], coords[3]
1812      );
1813      svg.push_str(&format!(r#"<path d="{d}"{rest} fill="none"/>"#));
1814    } else if coords.len() >= 3 {
1815      let d = format!("M {} Q {} {}", coords[0], coords[1], coords[2]);
1816      svg.push_str(&format!(r#"<path d="{d}"{rest} fill="none"/>"#));
1817    }
1818  }
1819
1820  svg
1821}
1822
1823/// Extract a `name="value"` attribute from a serialized element/attribute
1824/// string. Matches `name` only at an attribute boundary (string start,
1825/// whitespace, or after `<`), so a query for `width` does NOT match
1826/// `innerwidth="…"`. Returns the first such value.
1827fn svg_attr(s: &str, name: &str) -> Option<String> {
1828  let pat = format!("{name}=\"");
1829  let bytes = s.as_bytes();
1830  let mut from = 0;
1831  while let Some(rel) = s[from..].find(&pat) {
1832    let at = from + rel;
1833    let boundary = at == 0 || bytes[at - 1].is_ascii_whitespace() || bytes[at - 1] == b'<';
1834    if boundary {
1835      let vstart = at + pat.len();
1836      return s[vstart..]
1837        .find('"')
1838        .map(|end| s[vstart..vstart + end].to_string());
1839    }
1840    from = at + pat.len();
1841  }
1842  None
1843}
1844
1845/// Parse a TeX dimension string (e.g. "100.0pt") to pixels.
1846fn parse_tex_dim(s: &str) -> Option<f64> {
1847  let s = s.trim();
1848  if let Some(rest) = s.strip_suffix("pt") {
1849    rest.parse::<f64>().ok().map(|v| v * 96.0 / 72.27)
1850  } else if let Some(rest) = s.strip_suffix("px") {
1851    rest.parse::<f64>().ok()
1852  } else {
1853    s.parse::<f64>().ok()
1854  }
1855}
1856
1857#[cfg(test)]
1858mod finalize_html5_splice_tests {
1859  //! #398: the `ltx_picture` SVG splice must fill the placeholder span
1860  //! regardless of the serialized attribute ORDER or QUOTE style — the coupling
1861  //! the original `<span id="…" class="ltx_picture"…>` regex had. No live input
1862  //! perturbs the order today (the XSLT emits id-then-class, libxml2 preserves
1863  //! order and double-quotes), so these craft the perturbations directly to keep
1864  //! the match robust against a future serializer/XSLT change. The old regex
1865  //! FAILED cases 2-4 (class-first / single-quotes / an attribute between id and
1866  //! class); the hardened match passes them.
1867  use super::finalize_html5;
1868
1869  fn splice(span: &str) -> String {
1870    finalize_html5(span.to_string(), &[(
1871      "p1".to_string(),
1872      "<svg>OK</svg>".to_string(),
1873    )])
1874  }
1875
1876  #[test]
1877  fn splice_is_attribute_order_and_quote_agnostic() {
1878    // 1. Canonical (id-first, double-quote) — the only shape seen live.
1879    assert!(splice(r#"<span id="p1" class="ltx_picture"></span>"#).contains("<svg>OK</svg>"));
1880    // 2. class BEFORE id — the old regex required id-then-class.
1881    assert!(splice(r#"<span class="ltx_picture" id="p1"></span>"#).contains("<svg>OK</svg>"));
1882    // 3. single quotes — the old regex hard-coded double quotes.
1883    assert!(splice(r#"<span id='p1' class='ltx_picture'></span>"#).contains("<svg>OK</svg>"));
1884    // 4. an attribute BETWEEN id and class — the old regex needed them adjacent.
1885    assert!(
1886      splice(r#"<span id="p1" style="color:red" class="ltx_picture"></span>"#)
1887        .contains("<svg>OK</svg>")
1888    );
1889    // The placeholder's own attributes are preserved (only the content is filled).
1890    assert!(
1891      splice(r#"<span class="ltx_picture" id="p1"></span>"#).contains(r#"class="ltx_picture""#)
1892    );
1893  }
1894
1895  #[test]
1896  fn splice_leaves_non_targets_alone() {
1897    // Not a picture span → untouched.
1898    assert_eq!(
1899      splice(r#"<span id="p1" class="ltx_note"></span>"#),
1900      r#"<span id="p1" class="ltx_note"></span>"#
1901    );
1902    // A picture span with an id we have NO fragment for → untouched.
1903    assert_eq!(
1904      splice(r#"<span id="other" class="ltx_picture"></span>"#),
1905      r#"<span id="other" class="ltx_picture"></span>"#
1906    );
1907    // `ltx_picture` must be a whole class token, not a substring.
1908    assert_eq!(
1909      splice(r#"<span id="p1" class="ltx_picturewide"></span>"#),
1910      r#"<span id="p1" class="ltx_picturewide"></span>"#
1911    );
1912  }
1913}
1914
1915#[cfg(test)]
1916mod picture_svg_foreign_content_tests {
1917  //! html_feedback#74 (arXiv:0810.1673v3 Fig 2): a `<Math>` or `<graphics>`
1918  //! inside a `{picture}` `\makebox` must survive into the SVG, wrapped in a
1919  //! `<foreignObject>` (as Perl's `SVG.pm` does), not be dropped.
1920  //! `convert_picture_children_to_svg` handled the drawing primitives + `<text>`
1921  //! but had NO case for foreign content, so xfig/pstricks figures silently lost
1922  //! their math labels and `\epsfig` images. Plain text is the baseline; math and
1923  //! graphics are the canaries.
1924  use super::convert_picture_children_to_svg;
1925
1926  #[test]
1927  fn plain_text_label_still_renders() {
1928    // The shape emitted for `\put(50,50){\makebox(0,0){Hello}}` — already worked.
1929    let input = r#"<g transform="translate(50,50)"><text>Hello</text></g>"#;
1930    let out = convert_picture_children_to_svg(input);
1931    assert!(
1932      out.contains("Hello"),
1933      "plain-text picture label regressed: {out}"
1934    );
1935  }
1936
1937  #[test]
1938  fn math_label_survives_as_foreignobject() {
1939    // The shape emitted for `\put(20,20){\makebox(0,0){$X^{2}$}}`.
1940    let input = r#"<g transform="translate(20,20)" innerwidth="13.55pt" innerheight="8.14pt"><Math mode="inline" tex="X^{2}"><XMath><XMTok>X</XMTok></XMath></Math></g>"#;
1941    let out = convert_picture_children_to_svg(input);
1942    assert!(
1943      out.contains("foreignObject") && out.contains("Math"),
1944      "math inside a picture makebox was dropped, not wrapped in <foreignObject>: {out}"
1945    );
1946  }
1947
1948  #[test]
1949  fn embedded_graphic_survives_as_foreignobject() {
1950    // The shape emitted for `\epsfig{file=link.ps}` inside a picture.
1951    let input = r#"<g transform="translate(0,0)" innerwidth="277pt" innerheight="277pt"><graphics candidates="link.ps" graphic="link.ps"/></g>"#;
1952    let out = convert_picture_children_to_svg(input);
1953    assert!(
1954      out.contains("foreignObject") && out.contains("graphic"),
1955      "an embedded picture graphic was dropped, not wrapped in <foreignObject>: {out}"
1956    );
1957  }
1958}