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: ®ex::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}