latexml_post/graphics.rs
1//! Graphics postprocessing.
2//!
3//! Port of `LaTeXML::Post::Graphics`.
4//! Finds `<ltx:graphics>` elements without `imagesrc`, locates the source
5//! graphic file, applies transformations (scaling, cropping, format conversion),
6//! and sets the `imagesrc`, `imagewidth`, `imageheight` attributes.
7
8use std::{
9 path::{Path, PathBuf},
10 sync::LazyLock,
11};
12
13use libxml::tree::Node;
14use rustc_hash::FxHashMap as HashMap;
15
16use crate::{
17 document::PostDocument,
18 processor::{ProcessResult, Processor},
19};
20
21thread_local! {
22 /// The most recent converter-subprocess diagnostic on this thread: the program
23 /// name plus its captured stderr (or the spawn error). Surfaced into the
24 /// `imageprocessing:failed_to_convert` Error so an ENVIRONMENT failure is
25 /// self-explaining in the log instead of needing a strace — e.g. `gs` not
26 /// installed (`could not start … No such file or directory`), an AppArmor
27 /// denial (`gs: … /undefinedfilename …` while gs exits 0), or an ImageMagick
28 /// policy block. Set by `run_with_timeout`, cleared per node in `process`'s
29 /// worker loop, read+cleared at the Error site. Worker-thread-local — it rides
30 /// the same `logger::capture`/`replay_captured` fold to the main thread as the
31 /// Error itself.
32 static LAST_CONVERTER_DIAG: std::cell::RefCell<Option<String>> =
33 const { std::cell::RefCell::new(None) };
34}
35
36/// Record (overwrite) the latest converter diagnostic on this thread.
37fn record_converter_diag(msg: String) { LAST_CONVERTER_DIAG.with(|c| *c.borrow_mut() = Some(msg)); }
38
39/// Take (read + clear) this thread's latest converter diagnostic.
40fn take_converter_diag() -> Option<String> { LAST_CONVERTER_DIAG.with(|c| c.borrow_mut().take()) }
41
42/// Program name for the ImageMagick CLI delegate. On Windows, `convert.exe`
43/// is the system FAT→NTFS conversion utility in `System32`, which shadows
44/// ImageMagick's legacy name — invoking bare `convert` there runs the wrong
45/// program. ImageMagick 7's unified `magick` front-end accepts the same
46/// argument syntax, so use it on Windows. Unix keeps `convert` (matching
47/// Perl's Image::Magick-era delegate chain and ImageMagick 6 installs).
48fn im_convert_program() -> &'static str { if cfg!(windows) { "magick" } else { "convert" } }
49
50/// Program name for the Ghostscript CLI delegate. Unix installs `gs`.
51/// Windows Ghostscript ships the console binary as `gswin64c.exe`
52/// (32-bit: `gswin32c.exe`), MiKTeX bundles its own as `mgs.exe`, and
53/// TeX Live for Windows bundles one behind the `rungs.exe` wrapper
54/// (`tlpkg/tlgs`, same CLI) on the same bin dir as `kpsewhich` — so a
55/// TL-only box still gets a working EPS/PS chain with no extra install.
56/// Probed once per process, in that order, falling back to `gs` so a
57/// failure surfaces as the usual could-not-start converter diagnostic.
58fn gs_program() -> &'static str {
59 if cfg!(windows) {
60 // `which` applies the platform's own lookup rules (PATHEXT, so `.exe`/
61 // `.bat`/`.cmd` all resolve) — the same crate + semantics the kpathsea
62 // backend uses to find `kpsewhich`.
63 static GS: LazyLock<&'static str> = LazyLock::new(|| {
64 ["gswin64c", "gswin32c", "mgs", "rungs"]
65 .into_iter()
66 .find(|candidate| which::which(candidate).is_ok())
67 .unwrap_or("gs")
68 });
69 *GS
70 } else {
71 "gs"
72 }
73}
74
75/// Map a graphics-converter executable to a "what to install" hint, so a
76/// "not installed" diagnostic tells the user how to fix it rather than just
77/// naming a missing binary. Covers the optional tools the graphics cascade
78/// shells out to — none are bundled; the host TeX/graphics ecosystem provides
79/// them. Returns `None` for an unrecognized program (no hint appended).
80/// The Windows Ghostscript aliases (`gswin64c`/`gswin32c`/`mgs`/`rungs`, from
81/// `gs_program`) map to the same Ghostscript hint.
82fn missing_tool_hint(prog: &str) -> Option<&'static str> {
83 Some(match prog {
84 "gs" | "gswin64c" | "gswin32c" | "mgs" | "rungs" | "ps2pdf" => {
85 "install Ghostscript (apt `ghostscript`, brew `ghostscript`) for PDF/PostScript conversion"
86 },
87 "mutool" => "install MuPDF (apt `mupdf-tools`, brew `mupdf-tools`) for fast PDF rendering",
88 "pdftocairo" | "pdftoppm" => {
89 "install Poppler (apt `poppler-utils`, brew `poppler`) for vector-SVG PDF conversion"
90 },
91 "convert" | "magick" => {
92 "install ImageMagick (apt `imagemagick`, brew `imagemagick`) for raster image conversion"
93 },
94 "dvisvgm" => {
95 "install dvisvgm (apt `dvisvgm`, brew `texlive`) for vector-SVG LaTeX-image output"
96 },
97 "dvipng" => "install dvipng (apt `dvipng`, brew `texlive`) for raster LaTeX-image output",
98 "latex" | "pdflatex" | "kpsewhich" | "tftopl" => {
99 "install TeX Live (apt `texlive-latex-base`, brew `texlive` / MacTeX) — the TeX ecosystem \
100 must be present at runtime"
101 },
102 _ => return None,
103 })
104}
105
106// Diagnostic emission: `Error!` (and friends) live in
107// `crate::diag` and are exposed crate-wide via `#[macro_use] pub mod
108// diag;` in `lib.rs`. They emit harness-compatible structured Error
109// lines (`Error:<class>:<object> <msg>`) matching what
110// `latexml_core::common::error::Error!` produces.
111
112// Process-once cached env var (see WISDOM #56 — getenv hot-path race).
113// Parsed-and-validated at init: only positive integer values are
114// honored; everything else (unset, empty, "0", malformed) leaves
115// `SVG_CONVERT_TIMEOUT_SECS` at None and the caller falls back to the
116// 15-second default in `svg_convert_timeout_secs`.
117static SVG_CONVERT_TIMEOUT_SECS: LazyLock<Option<u64>> = LazyLock::new(|| {
118 std::env::var("LATEXML_SVG_CONVERT_TIMEOUT_SECS")
119 .ok()
120 .and_then(|s| s.parse::<u64>().ok())
121 .filter(|&n| n > 0)
122});
123
124/// Wall-clock timeout for the `convert` (ImageMagick / gs) subprocess.
125/// Defaults to 60 s; override via `LATEXML_CONVERT_TIMEOUT_SECS`. Same
126/// pattern as `SVG_CONVERT_TIMEOUT_SECS` — see WISDOM #56.
127static CONVERT_TIMEOUT_SECS: LazyLock<Option<u64>> = LazyLock::new(|| {
128 std::env::var("LATEXML_CONVERT_TIMEOUT_SECS")
129 .ok()
130 .and_then(|s| s.parse::<u64>().ok())
131 .filter(|&n| n > 0)
132});
133
134/// Properties for a graphics file type.
135#[derive(Debug, Clone)]
136pub struct TypeProperties {
137 pub destination_type: Option<String>,
138 pub transparent: bool,
139 pub prescale: bool,
140 pub ncolors: Option<String>,
141 pub quality: Option<u32>,
142 pub unit: String,
143 pub raster: Option<bool>,
144 pub autocrop: bool,
145 pub desirability: u32,
146}
147
148impl Default for TypeProperties {
149 fn default() -> Self {
150 TypeProperties {
151 destination_type: None,
152 transparent: false,
153 prescale: false,
154 ncolors: None,
155 quality: None,
156 unit: "pixel".to_string(),
157 raster: None,
158 autocrop: false,
159 desirability: 0,
160 }
161 }
162}
163
164/// Graphics post-processor.
165///
166/// Port of `LaTeXML::Post::Graphics`.
167pub struct Graphics {
168 name: String,
169 dpi: Option<u32>,
170 magnify: f64,
171 zoomout: f64,
172 trivial_scaling: bool,
173 graphics_types: Vec<String>,
174 type_properties: HashMap<String, TypeProperties>,
175 background: String,
176 /// Opt-in vector-SVG path for PDF graphics. When > 0, PDFs under this
177 /// many KB are first attempted via the vector converters (mutool, then
178 /// pdftocairo); fall back to the raster (`convert`/`gs` → PNG) path on
179 /// failure or timeout. 0 disables the path entirely.
180 /// Tracks upstream brucemiller/LaTeXML#902.
181 svg_threshold_kb: u32,
182 /// Whether conversions may be served from the shared, host-persistent
183 /// graphics cache. Threaded explicitly (rather than read from a global)
184 /// so a caller can guarantee its conversions actually run — see
185 /// [`crate::graphics_cache::CachePolicy`] and `with_cache_policy`.
186 cache_policy: crate::graphics_cache::CachePolicy,
187}
188
189impl Graphics {
190 // 120 dpi: a compromise between Perl's `$DPI = 100`
191 // (Util/Image.pm:37) and our prior 150. Empirically (1910.01256
192 // measured 2026-05-12) the dominant graphics-phase cost is vector
193 // primitive iteration in matplotlib/pgfplots PDFs, NOT pixel count
194 // — so density 100..150 produce near-identical wall on those
195 // papers. 120 keeps text and thin strokes legible on hidpi displays
196 // (Retina 144-192 dpi target) while shaving ~20-30% off the output
197 // PNG byte count.
198 // Override via `LATEXML_RASTER_DENSITY=<dpi>` for explicit control.
199 const DEFAULT_RASTER_DENSITY: u32 = 120;
200 const MAX_RASTER_DIMENSION_PX: u32 = 2048;
201
202 pub fn new(dpi: Option<u32>, trivial_scaling: bool) -> Self {
203 let mut type_properties = HashMap::default();
204
205 // Default type properties matching Perl.
206 // `.epsi` (EPS Interchange — EPS with optional embedded TIFF
207 // preview, e.g. HIGZ / CERN PAW output) and `.epsf` are EPS
208 // variants browsers can't render natively but `gs` rasterises
209 // identically to plain `.eps`. SURPASS-PERL: Perl LaTeXML also
210 // omits these from its type_properties so the files were copied
211 // verbatim and rendered as broken images. Witness:
212 // hep-ph0608319 Fig 1 (refit_av_extra.epsi, HIGZ 1.29/04 output).
213 for ext in &["ai", "pdf", "ps", "eps", "epsi", "epsf"] {
214 type_properties.insert(ext.to_string(), TypeProperties {
215 destination_type: Some("png".to_string()),
216 transparent: true,
217 prescale: true,
218 ncolors: Some("400%".to_string()),
219 quality: Some(90),
220 unit: "point".to_string(),
221 ..Default::default()
222 });
223 }
224 for ext in &["jpg", "jpeg"] {
225 type_properties.insert(ext.to_string(), TypeProperties {
226 destination_type: Some(ext.to_string()),
227 ncolors: Some("400%".to_string()),
228 unit: "pixel".to_string(),
229 ..Default::default()
230 });
231 }
232 type_properties.insert("gif".to_string(), TypeProperties {
233 destination_type: Some("gif".to_string()),
234 transparent: true,
235 ncolors: Some("400%".to_string()),
236 unit: "pixel".to_string(),
237 ..Default::default()
238 });
239 type_properties.insert("png".to_string(), TypeProperties {
240 destination_type: Some("png".to_string()),
241 transparent: true,
242 ncolors: Some("400%".to_string()),
243 unit: "pixel".to_string(),
244 ..Default::default()
245 });
246 type_properties.insert("svg".to_string(), TypeProperties {
247 destination_type: Some("svg".to_string()),
248 raster: Some(false),
249 desirability: 11,
250 ..Default::default()
251 });
252
253 Graphics {
254 name: "Graphics".to_string(),
255 dpi,
256 magnify: 1.0,
257 zoomout: 1.0,
258 trivial_scaling,
259 graphics_types: vec![
260 "svg",
261 "png",
262 "gif",
263 "jpg",
264 "jpeg",
265 "eps",
266 "epsi",
267 "epsf",
268 "ps",
269 "postscript",
270 "ai",
271 "pdf",
272 ]
273 .into_iter()
274 .map(String::from)
275 .collect(),
276 type_properties,
277 background: "#FFFFFF".to_string(),
278 svg_threshold_kb: 0,
279 cache_policy: crate::graphics_cache::CachePolicy::default(),
280 }
281 }
282
283 /// Choose whether conversions may be served from the shared,
284 /// host-persistent graphics cache. Defaults to
285 /// [`CachePolicy::Shared`](crate::graphics_cache::CachePolicy::Shared).
286 ///
287 /// Pass [`CachePolicy::Bypass`](crate::graphics_cache::CachePolicy::Bypass)
288 /// when the conversion *itself* is what matters — e.g. a test asserting
289 /// on how many converter subprocesses ran, which a cache hit would
290 /// silently satisfy without running any. The builder returns `self` so
291 /// it composes with `Graphics::new(...)`.
292 #[must_use]
293 pub fn with_cache_policy(mut self, policy: crate::graphics_cache::CachePolicy) -> Self {
294 self.cache_policy = policy;
295 self
296 }
297
298 /// Enable the vector-SVG path for PDFs under `kb` KB. When `kb == 0`
299 /// (default), the SVG path is fully disabled and all PDFs go through
300 /// ImageMagick `convert`. The builder returns `self` so it composes with
301 /// `Graphics::new(...)`.
302 pub fn with_svg_threshold_kb(mut self, kb: u32) -> Self {
303 self.svg_threshold_kb = kb;
304 self
305 }
306
307 /// Find the graphics source file for a node.
308 ///
309 /// Port of `Graphics::findGraphicFile`.
310 fn find_graphic_file(
311 &self,
312 _doc: &PostDocument,
313 node: &Node,
314 search_paths: &[String],
315 ) -> Option<String> {
316 let source = node.get_attribute("graphic")?;
317
318 // Check candidates attribute first (comma-separated list of found files)
319 // Perl: findGraphicFile checks each candidate path, resolving relative to search paths.
320 if let Some(candidates) = node.get_attribute("candidates") {
321 // Pick the best candidate by desirability
322 let mut best: Option<(String, i32)> = None;
323 for path in candidates.split(',') {
324 let path = path.trim();
325 if path.is_empty() {
326 continue;
327 }
328 // Try the path directly, then in each search directory
329 let resolved = if Path::new(path).exists() {
330 Some(path.to_string())
331 } else {
332 search_paths.iter().find_map(|sp| {
333 let candidate = format!("{}/{}", sp, path);
334 if Path::new(&candidate).exists() {
335 Some(candidate)
336 } else {
337 None
338 }
339 })
340 };
341 if let Some(resolved_path) = resolved {
342 let ext = Path::new(&resolved_path)
343 .extension()
344 .and_then(|e| e.to_str())
345 .unwrap_or("")
346 .to_lowercase();
347 // Skip candidates whose extension is NOT a known graphics type.
348 // Perl's `findGraphicFile` re-searches with `types =>
349 // getGraphicsSourceTypes` (Post/Graphics.pm L150-151), which excludes
350 // non-graphics types — notably `.pdf_tex`, the inkscape "PDF+LaTeX"
351 // wrapper that is `\input`'d (it itself does `\includegraphics{grid}`),
352 // NOT a raster/vector image. `image_candidates` is deliberately
353 // unfiltered (matching Perl's `types => ['*']`), so a sibling
354 // `grid.pdf_tex` lands in the `candidates` attribute next to the real
355 // `grid.pdf`/`grid.eps`; without this filter it sorts first and gets
356 // picked, then fails to convert (`pdf_tex` has no destination_type).
357 // Empty ext is kept (the file may carry no extension but known content).
358 // Witness 1907.12308 (`\input{grid.pdf_tex}` → `\includegraphics{grid}`).
359 if !ext.is_empty() && !self.graphics_types.iter().any(|t| t == &ext) {
360 continue;
361 }
362 let props = self.type_properties.get(&ext);
363 let d = props.map(|p| p.desirability as i32).unwrap_or(0);
364 let is_same_type = props
365 .and_then(|p| p.destination_type.as_ref())
366 .map(|dt| dt == &ext)
367 .unwrap_or(false);
368 let desirability = if is_same_type { 10 } else { d };
369 if best.as_ref().is_none_or(|(_, bd)| desirability > *bd) {
370 best = Some((resolved_path, desirability));
371 }
372 }
373 }
374 if let Some((path, _)) = best {
375 return Some(path);
376 }
377 }
378
379 // Search for the file in search paths
380 let path = Path::new(&source);
381 let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or(&source);
382 let dir = path.parent().and_then(|p| p.to_str()).unwrap_or("");
383
384 let file_base = if dir.is_empty() {
385 name.to_string()
386 } else {
387 format!("{}/{}", dir, name)
388 };
389
390 // Try each type in search paths
391 let mut best_desirability: i32 = -1;
392 let mut best_path: Option<String> = None;
393
394 let types: Vec<String> = self
395 .graphics_types
396 .iter()
397 .flat_map(|t| vec![t.clone(), t.to_uppercase()])
398 .collect();
399
400 for search_path in search_paths {
401 // Try without extension first (source might already have one)
402 let candidate = if search_path.is_empty() {
403 source.clone()
404 } else {
405 format!("{}/{}", search_path, source)
406 };
407 if Path::new(&candidate).exists() {
408 let ext = Path::new(&candidate)
409 .extension()
410 .and_then(|e| e.to_str())
411 .unwrap_or("")
412 .to_lowercase();
413 let props = self.type_properties.get(&ext);
414 let d = props.map(|p| p.desirability as i32).unwrap_or(5);
415 if d > best_desirability {
416 best_desirability = d;
417 best_path = Some(candidate);
418 }
419 }
420 // Try each extension
421 for ext in &types {
422 let candidate = format!("{}/{}.{}", search_path, file_base, ext);
423 if Path::new(&candidate).exists() {
424 let props = self.type_properties.get(&ext.to_lowercase());
425 let d = props.map(|p| p.desirability as i32).unwrap_or(0);
426 let is_same_type = props
427 .and_then(|p| p.destination_type.as_ref())
428 .map(|dt| dt == &ext.to_lowercase())
429 .unwrap_or(false);
430 let desirability = if is_same_type { 10 } else { d };
431 if desirability > best_desirability {
432 best_desirability = desirability;
433 best_path = Some(candidate);
434 }
435 }
436 }
437 }
438 if best_path.is_some() {
439 return best_path;
440 }
441
442 // kpathsea-parity casefold fallback (`texmf_casefold_search`, default ON
443 // since TL2018): pdflatex finds `images/NLPOptNet.jpg` even when the
444 // file on disk is `images/NLPOPtNet.jpg`, so an author case-typo that
445 // builds fine on arXiv must not lose the figure here (witness
446 // 2605.00260, Figure 1). Like kpathsea, this fires only AFTER every
447 // exact search misses; only the filename component is folded, and an
448 // ambiguous directory (two case-variants) refuses to guess.
449 for sp in std::iter::once(String::new()).chain(search_paths.iter().cloned()) {
450 let cand = if sp.is_empty() {
451 source.clone()
452 } else {
453 format!("{}/{}", sp, source)
454 };
455 if let Some(found) = Self::casefold_resolve(&cand) {
456 Info!(
457 "graphics",
458 "casefold",
459 "Graphic '{}' resolved case-insensitively to '{}' (kpathsea casefold parity)",
460 source,
461 found
462 );
463 return Some(found);
464 }
465 }
466 None
467 }
468
469 /// Case-insensitive filename resolution within the path's directory.
470 /// Returns the unique match, or None when absent or ambiguous.
471 fn casefold_resolve(path: &str) -> Option<String> {
472 let p = Path::new(path);
473 if p.exists() {
474 return Some(path.to_string());
475 }
476 let parent = match p.parent() {
477 Some(d) if !d.as_os_str().is_empty() => d,
478 _ => Path::new("."),
479 };
480 let want = p.file_name()?.to_str()?;
481 let mut hit: Option<String> = None;
482 for entry in std::fs::read_dir(parent).ok()? {
483 let entry = entry.ok()?;
484 let name = entry.file_name();
485 let Some(name) = name.to_str() else { continue };
486 if name.eq_ignore_ascii_case(want) && entry.path().is_file() {
487 if hit.is_some() {
488 return None; // ambiguous — never guess between case-variants
489 }
490 hit = Some(entry.path().to_string_lossy().to_string());
491 }
492 }
493 hit
494 }
495
496 /// Set the image source attributes on a graphics node.
497 ///
498 /// Port of `Graphics::setGraphicSrc`.
499 fn set_graphic_src(node: &mut Node, src: &str, width: Option<u32>, height: Option<u32>) {
500 node.set_attribute("imagesrc", src).ok();
501 // HTML width/height are in pixels (unitless)
502 if let Some(w) = width {
503 node.set_attribute("imagewidth", &w.to_string()).ok();
504 }
505 if let Some(h) = height {
506 node.set_attribute("imageheight", &h.to_string()).ok();
507 }
508 // Set aspect ratio class
509 if let (Some(w), Some(h)) = (width, height) {
510 let class = if w as f64 > 1.24 * h as f64 {
511 "ltx_img_landscape"
512 } else if h as f64 > 1.24 * w as f64 {
513 "ltx_img_portrait"
514 } else {
515 "ltx_img_square"
516 };
517 let existing = node.get_attribute("class").unwrap_or_default();
518 let new_class = if existing.is_empty() {
519 class.to_string()
520 } else {
521 format!("{} {}", existing, class)
522 };
523 node.set_attribute("class", &new_class).ok();
524 }
525 }
526
527 /// Find graphicspath from processing instructions.
528 fn find_graphics_paths(&self, doc: &PostDocument) -> Vec<String> {
529 // Perl `Post/Graphics.pm:91`:
530 // [map { pathname_canonical($_) }
531 // $self->findGraphicsPaths($doc), $doc->getSearchPaths]
532 // — the search paths are the union of graphicspath PIs PLUS the
533 // document's own search paths (typically the source directory). The
534 // prior Rust port included only the PI half, which left every paper
535 // with raw `.ps`/`.eps`/etc. files in the source directory (and no
536 // explicit `\graphicspath{...}`) emitting `Error:expected:source`
537 // for every figure, even though the source files are present.
538 // Driver: astro-ph0002170 (8 .ps figures in the zip, all "not found").
539 use std::sync::LazyLock;
540 static GRAPHICSPATH_RE: LazyLock<regex::Regex> =
541 LazyLock::new(|| regex::Regex::new(r#"^\s*graphicspath\s*=\s*[\"'](.*?)[\"']\s*$"#).unwrap());
542 let mut paths = Vec::new();
543 for pi in doc.findnodes("//processing-instruction('latexml')") {
544 let text = pi.get_content();
545 if let Some(cap) = GRAPHICSPATH_RE.captures(&text) {
546 paths.push(cap[1].to_string());
547 }
548 }
549 paths.extend(doc.get_search_paths().iter().cloned());
550 paths
551 }
552
553 /// Read a named parameter from processing instructions.
554 /// Port of Perl's `Graphics::getParameter`.
555 /// Checks both direct PI (`<?latexml DPI="100"?>`) and
556 /// latexml.sty package options (`<?latexml package="latexml" options="magnify=1.2"?>`).
557 fn get_parameter(&self, doc: &PostDocument, param: &str) -> Option<f64> {
558 let direct_re = regex::Regex::new(&format!(
559 r#"^\s*{}\s*=\s*[\"']?([\d.]+)[\"']?\s*$"#,
560 regex::escape(param)
561 ))
562 .ok()?;
563 let options_re =
564 regex::Regex::new(r#"package\s*=\s*[\"']latexml[\"'].*options\s*=\s*[\"'](.*?)[\"']"#)
565 .ok()?;
566 let param_in_options_re =
567 regex::Regex::new(&format!(r#"\b{}\s*=\s*([\d.]+)"#, regex::escape(param))).ok()?;
568
569 for pi in doc.findnodes("//processing-instruction('latexml')") {
570 let text = pi.get_content();
571 if let Some(cap) = direct_re.captures(&text) {
572 return cap[1].parse().ok();
573 }
574 if let Some(cap) = options_re.captures(&text) {
575 if let Some(inner) = param_in_options_re.captures(&cap[1]) {
576 return inner[1].parse().ok();
577 }
578 }
579 }
580 None
581 }
582
583 /// Read image dimensions using imagesize crate.
584 /// Returns (width, height) in pixels.
585 fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
586 match imagesize::size(path) {
587 Ok(dim) => Some((dim.width as u32, dim.height as u32)),
588 Err(_) => None,
589 }
590 }
591
592 /// Read a *source* graphic's intrinsic dimensions, dispatching on type.
593 ///
594 /// Port of Perl `Util/Image.pm:image_size` (L86-97), which sizes ANY
595 /// supported source type — there Image::Magick covers SVG. The
596 /// `imagesize` crate is raster-only, so SVG dispatches to
597 /// `read_svg_dimensions` (already the SVG-dims reader on the PDF→SVG
598 /// convert path). Without this, a web-native SVG going through the
599 /// trivial-copy path got NO imagewidth/imageheight and rendered at
600 /// intrinsic size, ignoring `\includegraphics[width=…]` (issue 498).
601 fn read_source_dimensions(path: &str) -> Option<(u32, u32)> {
602 let is_svg = Path::new(path)
603 .extension()
604 .and_then(|e| e.to_str())
605 .is_some_and(|e| e.eq_ignore_ascii_case("svg"));
606 if is_svg {
607 Self::read_svg_dimensions(path)
608 } else {
609 Self::read_image_dimensions(path)
610 }
611 }
612
613 /// Parse `angle=N` from graphicx options. Returns angle normalised
614 /// to one of {0, 90, 180, 270} when within 5° of those targets,
615 /// otherwise the raw float (rotation of arbitrary angles is
616 /// handled separately and is more complex due to bounding-box
617 /// changes).
618 fn parse_angle_option(options: &str) -> Option<f64> {
619 for opt in options.split(',') {
620 let opt = opt.trim();
621 if let Some((key, val)) = opt.split_once('=') {
622 if key.trim() == "angle" {
623 return val.trim().parse::<f64>().ok();
624 }
625 }
626 }
627 None
628 }
629
630 /// Apply the graphicx options to a measured pixel size.
631 ///
632 /// Delegates to the shared algebra in `latexml_core::util::image` — the port
633 /// of Perl `image_graphicx_size` (`Util/Image.pm` L221-256) — so the pixel
634 /// count written into the HTML and the box the engine reserved come from one
635 /// implementation. Pixel space, hence `DPI/72.27` per bp and Perl's `ceil` at
636 /// each step.
637 ///
638 /// Two behaviours changed when this stopped being its own implementation, both
639 /// toward Perl:
640 ///
641 /// * **Option values now go through `to_bp` first.** The local parser stripped
642 /// a `pt` suffix and used the number as-is, skipping Perl's pt->bp step, so
643 /// every explicit size came out up to one pixel wider than the engine's box
644 /// for the same request (`width=100pt`: 139 px here vs 138 in the engine).
645 /// It also silently ignored every unit that was not `pt`/`px`.
646 /// * **Rotation is the true bounding box**, not a 90/270 axis swap. That
647 /// matches what `convert -rotate` actually writes to disk for an oblique
648 /// angle. It is ordered relative to scaling by key order, as Perl and
649 /// graphicx do — see `parse_graphicx_options`. Witness for the rotation
650 /// mattering at all: 1303.5091 Figs 5-7, `[angle=90,scale=0.75]`, which
651 /// rendered upside-down before any of it — still correct here: a uniform
652 /// scale commutes with a rotation, so its order does not change the box.
653 ///
654 /// Witness for the width-only path: astro-ph0005397 Fig 11 (sfh_burst), where
655 /// feeding the unscaled raw height back through displayed square sources as
656 /// 4:1 ribbons.
657 fn apply_graphicx_transforms(raw_w: u32, raw_h: u32, options: &str, dpi: u32) -> (u32, u32) {
658 let ops = latexml_core::util::image::parse_graphicx_options(options);
659 let (w, h) = latexml_core::util::image::apply_graphicx_ops(
660 raw_w as f64,
661 raw_h as f64,
662 &ops,
663 dpi as f64 / 72.27,
664 true,
665 );
666 // An image attribute of 0 is useless to a browser; keep at least one pixel.
667 (w.max(1.0) as u32, h.max(1.0) as u32)
668 }
669
670 /// Physically rotate a rasterized image via `convert -rotate N`.
671 /// Called after the rasterizer produces a PNG when graphicx
672 /// options include `angle=N`. Returns true on success.
673 /// Pre-condition: `dest` exists. Post-condition: `dest` is
674 /// in-place rotated.
675 /// Content fingerprint for graphics-asset deduplication. SipHash
676 /// (`std::collections::hash_map::DefaultHasher`) over the file's
677 /// raw bytes. Returns `None` if the file can't be opened.
678 ///
679 /// Collisions are theoretically possible but astronomically unlikely
680 /// for paper-sized graphics dirs (worst case: a few hundred files
681 /// per paper, all under 50 MB). We use a u64 hash as the dedup key.
682 ///
683 /// Purpose: byte-identical files `x1.pdf` and `x2.pdf` (e.g. shared
684 /// figures across subsections, or duplicated by the author) should
685 /// share one rasterized PNG/SVG in the output bundle. Both `<img>`
686 /// tags then reference the first-seen filename's stem — saves both
687 /// conversion time AND output-bundle disk space.
688 fn hash_file_content(path: &str) -> Option<u64> {
689 use std::{hash::Hasher, io::Read};
690 let mut file = std::fs::File::open(path).ok()?;
691 let mut hasher = std::collections::hash_map::DefaultHasher::new();
692 let mut buf = [0u8; 65536];
693 loop {
694 let n = file.read(&mut buf).ok()?;
695 if n == 0 {
696 break;
697 }
698 hasher.write(&buf[..n]);
699 }
700 Some(hasher.finish())
701 }
702
703 fn rotate_image_inplace(dest: &str, angle_deg: f64) -> bool {
704 // Sibling temp file to avoid IM's flaky in-place rewrite semantics.
705 let dest_path = Path::new(dest);
706 let parent = dest_path.parent().unwrap_or_else(|| Path::new("."));
707 let stem = dest_path
708 .file_name()
709 .and_then(|s| s.to_str())
710 .unwrap_or("image");
711 let unique = std::time::SystemTime::now()
712 .duration_since(std::time::UNIX_EPOCH)
713 .map(|d| d.as_nanos())
714 .unwrap_or(0);
715 let tmp = parent.join(format!(".{}.{}.rotated", stem, unique));
716 let mut cmd = std::process::Command::new(im_convert_program());
717 cmd
718 .arg(dest)
719 .arg("-rotate")
720 .arg(format!("{}", angle_deg))
721 .arg(&tmp);
722 let timeout = std::time::Duration::from_secs(30);
723 let cmd_ok = Self::run_with_timeout(cmd, timeout)
724 .map(|s| s.success())
725 .unwrap_or(false)
726 && tmp.exists();
727 if !cmd_ok {
728 let _ = std::fs::remove_file(&tmp);
729 return false;
730 }
731 let renamed = std::fs::rename(&tmp, dest)
732 .or_else(|_| std::fs::copy(&tmp, dest).map(|_| ()))
733 .is_ok();
734 let _ = std::fs::remove_file(&tmp);
735 renamed
736 }
737
738 /// Copy a source image to the destination directory, preserving relative paths.
739 /// Returns the destination path (relative to dest_dir).
740 fn copy_to_destination(source: &str, source_dir: &str, dest_dir: &str) -> Option<String> {
741 // Compute relative path of source from source_dir
742 let source_path = Path::new(source);
743 let source_base = Path::new(source_dir);
744 let rel_path = source_path.strip_prefix(source_base).unwrap_or(source_path);
745
746 // Build absolute destination path
747 let abs_dest = PathBuf::from(dest_dir).join(rel_path);
748
749 // Create parent directories if needed
750 if let Some(parent) = abs_dest.parent() {
751 std::fs::create_dir_all(parent).ok()?;
752 }
753
754 // Copy the file (skip if same path)
755 let source_canonical = std::fs::canonicalize(source).ok();
756 let dest_canonical = std::fs::canonicalize(&abs_dest).ok();
757 if source_canonical != dest_canonical || dest_canonical.is_none() {
758 std::fs::copy(source, &abs_dest).ok()?;
759 }
760
761 // Return relative path for imagesrc attribute
762 Some(rel_path.to_string_lossy().to_string())
763 }
764
765 /// Extract `page=N` from graphicx options string.
766 /// Returns 1-based page number (matching graphicx convention), or None.
767 fn parse_page_option(options: &str) -> Option<u32> {
768 for opt in options.split(',') {
769 let opt = opt.trim();
770 if let Some((key, val)) = opt.split_once('=') {
771 if key.trim() == "page" {
772 // Strip braces: page={2} → 2
773 let val = val.trim().trim_matches('{').trim_matches('}');
774 return val.parse::<u32>().ok();
775 }
776 }
777 }
778 None
779 }
780
781 /// Try to convert a PDF to plain SVG, preserving vector content. Returns
782 /// `true` on success. Tracks upstream brucemiller/LaTeXML#902.
783 ///
784 /// Caller decides when to attempt this — typically only for PDF sources
785 /// below a file-size threshold (`should_try_svg_path`), because vector
786 /// converters on raster-embedded PDFs produce massive output (>100 MB).
787 /// On any failure the function returns `false` and the worker falls back
788 /// to the raster (PNG) path — so a PDF that no vector tool can handle is
789 /// never lost, just rasterized instead.
790 ///
791 /// `page` is 1-based (graphicx convention).
792 ///
793 /// Each converter is guarded by a **hard timeout** (15 s default; see
794 /// `svg_convert_timeout_secs`) and an output-size cap
795 /// (`MAX_SVG_OUTPUT_BYTES`): a converter still running after the deadline
796 /// is SIGKILLed, and oversized output is discarded — either way we fall
797 /// through to the next converter, then to raster.
798 fn convert_image_svg(source: &str, dest: &str, page: Option<u32>) -> bool {
799 // Try the two fast vector converters in order of measured speed +
800 // gzip-compressibility on the canvas slow-tail. Each is gated by
801 // `MAX_SVG_OUTPUT_BYTES`; pathological vector-heavy PDFs (e.g.
802 // R-Graphics `W.pdf`) can emit >100 MB SVG which we discard so the
803 // caller falls back to raster.
804 //
805 // Order (subprocess; library license doesn't propagate):
806 // 1. mutool (MuPDF CLI) — fastest, plus ~4× more gzip-compressible SVG output than pdftocairo
807 // (1.5 MB vs 6.0 MB gz on matplotlib scatter).
808 // 2. pdftocairo (poppler) — universally available with TeX Live; parses vector PDFs directly,
809 // so it's fast even on the inputs that make ImageMagick/gs rasterization crawl.
810 //
811 // A heavyweight third resort (inkscape) was removed deliberately: it
812 // pulls a GTK/X11 stack, is 20-40× slower, and is timeout-prone, while
813 // adding no real coverage — when both converters above fail, the worker
814 // rasterizes to PNG anyway.
815 if Self::convert_image_svg_mutool(source, dest, page) {
816 return true;
817 }
818 if Self::convert_image_svg_pdftocairo(source, dest, page) {
819 return true;
820 }
821 false
822 }
823
824 /// Maximum acceptable SVG output size from a vector conversion. Above
825 /// this we discard the SVG and force the raster fallback — it's nearly
826 /// always cheaper to ship a 30 KB PNG than a 100 MB SVG even when both
827 /// are technically valid. Tuned from observed cases: well-behaved
828 /// matplotlib plots are ~500 KB - 2 MB; W.pdf-class explodes to
829 /// 70-115 MB across all known PDF→SVG tools.
830 const MAX_SVG_OUTPUT_BYTES: u64 = 8 * 1024 * 1024; // 8 MB
831
832 fn svg_output_too_large(path: &str) -> bool {
833 std::fs::metadata(path)
834 .map(|md| md.len() > Self::MAX_SVG_OUTPUT_BYTES)
835 .unwrap_or(false)
836 }
837 /// `mutool convert -F svg` (MuPDF CLI) — first-choice SVG vector
838 /// converter. Faster than `pdftocairo -svg` on vector-heavy PDFs
839 /// (~2×), AND produces output that gzip-compresses ~4× better when
840 /// served as `.svgz`. Subprocess invocation — no MuPDF code linked.
841 ///
842 /// Measured 2026-05-12 on matplotlib AugmentedMSRA10K…pos.pdf:
843 /// pdftocairo -svg: 1.17 s, 29.9 MB raw, 6.0 MB gz
844 /// mutool convert: 0.52 s, 29.7 MB raw, 1.5 MB gz
845 ///
846 /// mutool's `convert` emits one file per page via a printf-style
847 /// pattern. We use `%d` to capture the requested page and rename
848 /// the result to the caller's `dest`.
849 fn convert_image_svg_mutool(source: &str, dest: &str, page: Option<u32>) -> bool {
850 let dest_path = Path::new(dest);
851 let parent = dest_path.parent().unwrap_or_else(|| Path::new("."));
852 let stem = dest_path
853 .file_name()
854 .and_then(|s| s.to_str())
855 .unwrap_or("image");
856 let unique = std::time::SystemTime::now()
857 .duration_since(std::time::UNIX_EPOCH)
858 .map(|d| d.as_nanos())
859 .unwrap_or(0);
860 let tmp_pattern = parent.join(format!(".{}.{}.mutool_svg%d.svg", stem, unique));
861 let tmp_pattern_str = tmp_pattern.to_string_lossy().to_string();
862 let p1 = page.map(|p| p.max(1)).unwrap_or(1);
863 let tmp_actual = parent.join(format!(".{}.{}.mutool_svg{}.svg", stem, unique, p1));
864 let cleanup = || {
865 let _ = std::fs::remove_file(&tmp_actual);
866 };
867
868 let mut cmd = std::process::Command::new("mutool");
869 cmd
870 .arg("convert")
871 .arg("-F")
872 .arg("svg")
873 .arg("-o")
874 .arg(&tmp_pattern_str)
875 .arg(source)
876 .arg(p1.to_string());
877 let timeout = std::time::Duration::from_secs(Self::svg_convert_timeout_secs());
878 let ok = Self::run_with_timeout(cmd, timeout)
879 .map(|status| status.success())
880 .unwrap_or(false)
881 && tmp_actual.exists();
882 if !ok {
883 cleanup();
884 return false;
885 }
886 if std::fs::metadata(&tmp_actual)
887 .map(|md| md.len() > Self::MAX_SVG_OUTPUT_BYTES)
888 .unwrap_or(true)
889 {
890 cleanup();
891 return false;
892 }
893 let _ = std::fs::remove_file(dest);
894 let installed = std::fs::rename(&tmp_actual, dest)
895 .or_else(|_| std::fs::copy(&tmp_actual, dest).map(|_| ()))
896 .is_ok()
897 && dest_path.exists();
898 cleanup();
899 installed
900 }
901
902 /// `pdftocairo -svg` rasterizes the page's vector content to SVG via
903 /// poppler/cairo — the second-choice vector converter after mutool, and
904 /// universally available with TeX Live. Returns true ONLY if the output
905 /// is reasonably-sized; otherwise we discard it and the caller falls
906 /// through to the raster (PNG) path.
907 fn convert_image_svg_pdftocairo(source: &str, dest: &str, page: Option<u32>) -> bool {
908 let mut cmd = std::process::Command::new("pdftocairo");
909 cmd.arg("-svg");
910 if let Some(p) = page {
911 let p1 = p.max(1);
912 cmd
913 .arg("-f")
914 .arg(p1.to_string())
915 .arg("-l")
916 .arg(p1.to_string());
917 } else {
918 cmd.arg("-f").arg("1").arg("-l").arg("1");
919 }
920 cmd.arg(source).arg(dest);
921 let timeout = std::time::Duration::from_secs(Self::svg_convert_timeout_secs());
922 match Self::run_with_timeout(cmd, timeout) {
923 Some(status) => {
924 if !(status.success() && Path::new(dest).exists()) {
925 let _ = std::fs::remove_file(dest);
926 return false;
927 }
928 if Self::svg_output_too_large(dest) {
929 let _ = std::fs::remove_file(dest);
930 return false;
931 }
932 true
933 },
934 None => {
935 let _ = std::fs::remove_file(dest);
936 false
937 },
938 }
939 }
940
941 /// Hard timeout (seconds) for a vector-SVG converter subprocess (mutool /
942 /// pdftocairo). Overridable via the `LATEXML_SVG_CONVERT_TIMEOUT_SECS`
943 /// environment variable for debugging; defaults to 15 s — enough for all
944 /// benign vector-authored plots we've measured (< 1 s typical), strict
945 /// enough to cut off the Fade.pdf-class 40 s+ runaway cases.
946 fn svg_convert_timeout_secs() -> u64 { SVG_CONVERT_TIMEOUT_SECS.unwrap_or(15) }
947
948 /// Run `cmd` and enforce a wall-clock timeout. Returns `Some(status)` on
949 /// clean exit, `None` if the child was killed on timeout or spawn
950 /// failed. Polls every 50 ms — cheap compared to the subprocess cost.
951 ///
952 /// On Unix, each child runs in its OWN session (setsid via pre_exec)
953 /// so a timeout kill targets the entire process group with `killpg`.
954 /// Without that, ImageMagick's `convert` was spawning `gs` and dying
955 /// on SIGKILL while leaving gs orphaned — those gs processes held on
956 /// for 10+ minutes per pathological PDF and stalled large sandbox
957 /// runs. The same hardening protects mutool / pdftocairo / ps2pdf.
958 fn run_with_timeout(
959 mut cmd: std::process::Command,
960 timeout: std::time::Duration,
961 ) -> Option<std::process::ExitStatus> {
962 // Capture the program name for the failure diagnostic before `cmd` is moved.
963 let prog = cmd.get_program().to_string_lossy().into_owned();
964 // stdout is uninteresting (null); stderr is PIPED so a converter's error text
965 // (gs `/undefinedfilename`, an ImageMagick policy block, a missing delegate)
966 // can be surfaced into the failed_to_convert Error. A draining reader thread
967 // (below) keeps the child from blocking on a full stderr pipe.
968 cmd
969 .stdout(std::process::Stdio::null())
970 .stderr(std::process::Stdio::piped());
971 #[cfg(unix)]
972 {
973 use std::os::unix::process::CommandExt;
974 // SAFETY: setsid(2) is async-signal-safe and is the documented way
975 // to make a child process group leader between fork() and exec().
976 unsafe {
977 cmd.pre_exec(|| {
978 // SAFETY: same as above — async-signal-safe call permitted here.
979 if libc::setsid() == -1 {
980 // Fall back: setpgid(0, 0). If both fail we proceed anyway.
981 let _ = libc::setpgid(0, 0);
982 }
983 // Die-with-watcher: `setsid` detaches this converter from every
984 // process group, so if the process running `run_with_timeout` is
985 // itself killed (e.g. the LSP server SIGKILLs a preempted body
986 // child mid-post-processing), nothing would ever time out or kill
987 // a runaway gs/convert — the exact orphan pathology this
988 // function's group-kill solves, reintroduced one level up.
989 // PR_SET_PDEATHSIG makes the kernel SIGKILL the converter when its
990 // spawning thread dies; it survives execve, so setting it here
991 // (between fork and exec) covers the exec'd tool. Linux-only —
992 // elsewhere the orphan window simply remains. prctl is
993 // async-signal-safe. Guard the fork→prctl race: if the watcher
994 // died before prctl took effect we are already reparented (to
995 // init or a subreaper) — exit instead of running unwatched.
996 #[cfg(target_os = "linux")]
997 {
998 libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
999 if libc::getppid() == 1 {
1000 libc::_exit(127);
1001 }
1002 }
1003 Ok(())
1004 });
1005 }
1006 }
1007 let mut child = match cmd.spawn() {
1008 Ok(c) => c,
1009 Err(e) => {
1010 // Spawn failure is the "converter not installed / not on PATH" case
1011 // (e.g. `gs` absent in a minimal image): record it so the Error names
1012 // the missing tool instead of a bare "failed to convert". When the
1013 // binary is simply absent, append the package/install hint so the user
1014 // learns which dependency to install (these tools are NOT bundled — the
1015 // host provides them).
1016 let mut msg = format!("could not start `{prog}`: {e}");
1017 if e.kind() == std::io::ErrorKind::NotFound {
1018 if let Some(hint) = missing_tool_hint(&prog) {
1019 msg.push_str(" — ");
1020 msg.push_str(hint);
1021 }
1022 }
1023 record_converter_diag(msg);
1024 return None;
1025 },
1026 };
1027 // Drain stderr concurrently into a bounded (8 KiB) buffer so the child never
1028 // blocks on a full pipe; joined after it exits.
1029 // The drained text comes back over a channel so the parent can WAIT
1030 // BOUNDED: a converter descendant that survives the kill while holding
1031 // stderr open must not hang the worker past the timeout (the reader
1032 // thread is simply abandoned; it exits at pipe EOF).
1033 let (stderr_tx, stderr_rx) = std::sync::mpsc::channel::<String>();
1034 let stderr_reader = child.stderr.take().map(|mut se| {
1035 std::thread::spawn(move || {
1036 use std::io::Read;
1037 let mut kept: Vec<u8> = Vec::new();
1038 let mut chunk = [0u8; 4096];
1039 loop {
1040 match se.read(&mut chunk) {
1041 Ok(0) => break,
1042 Ok(n) => {
1043 if kept.len() < 8192 {
1044 let room = 8192 - kept.len();
1045 kept.extend_from_slice(&chunk[..n.min(room)]);
1046 }
1047 },
1048 // EINTR is not EOF — treating it as terminal stopped the drain
1049 // and let a chatty converter block on the full pipe.
1050 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1051 Err(_) => break,
1052 }
1053 }
1054 let _ = stderr_tx.send(String::from_utf8_lossy(&kept).trim().to_string());
1055 })
1056 });
1057 let pid = child.id() as i32;
1058 let kill_group = || {
1059 #[cfg(unix)]
1060 {
1061 // SIGTERM the whole group first (graceful), then SIGKILL after
1062 // a brief grace if the leader is still alive. This matches what
1063 // `timeout(1) --kill-after` does for the bench script's outer
1064 // guard.
1065 // SAFETY: killpg(2) on a known pid is documented + safe.
1066 unsafe {
1067 libc::killpg(pid, libc::SIGTERM);
1068 }
1069 std::thread::sleep(std::time::Duration::from_millis(200));
1070 // SAFETY: pgid is the child's own process group (set via setpgid in
1071 // pre_exec); killpg only signals that group.
1072 unsafe {
1073 libc::killpg(pid, libc::SIGKILL);
1074 }
1075 }
1076 #[cfg(windows)]
1077 {
1078 // Windows analogue of the killpg group-kill: `taskkill /T` walks
1079 // the child-process tree from the given PID, so a timed-out
1080 // `magick` also takes down the `gs` it spawned (the exact orphan
1081 // scenario the Unix setsid+killpg design exists for). /F because
1082 // there is no SIGTERM-style graceful tier on Windows consoles
1083 // without a console-event dance; the subsequent child.kill() is
1084 // then a no-op backstop.
1085 let _ = std::process::Command::new("taskkill")
1086 .args(["/PID", &pid.to_string(), "/T", "/F"])
1087 .output();
1088 }
1089 #[cfg(not(any(unix, windows)))]
1090 {
1091 // Other platforms: best-effort PID kill only (child.kill() below).
1092 let _ = pid;
1093 }
1094 };
1095 let start = std::time::Instant::now();
1096 let status = loop {
1097 match child.try_wait() {
1098 Ok(Some(status)) => break Some(status),
1099 Ok(None) => {
1100 if start.elapsed() >= timeout {
1101 kill_group();
1102 let _ = child.kill();
1103 let _ = child.wait();
1104 break None;
1105 }
1106 std::thread::sleep(std::time::Duration::from_millis(50));
1107 },
1108 Err(_) => {
1109 kill_group();
1110 let _ = child.kill();
1111 let _ = child.wait();
1112 break None;
1113 },
1114 }
1115 };
1116 // Join the stderr drainer and record a diagnostic. gs can print a fatal
1117 // error (e.g. `/undefinedfilename` under an AppArmor denial) to stderr yet
1118 // still exit 0, so a non-empty stderr is always worth surfacing — not only
1119 // on a non-zero exit.
1120 let stderr_text = if stderr_reader.is_some() {
1121 // Exited child → EOF imminent; killed child → give the drain a short
1122 // grace, then abandon the reader rather than hang the worker.
1123 let grace = if status.is_some() {
1124 std::time::Duration::from_secs(10)
1125 } else {
1126 std::time::Duration::from_secs(2)
1127 };
1128 stderr_rx.recv_timeout(grace).unwrap_or_default()
1129 } else {
1130 String::new()
1131 };
1132 if !stderr_text.is_empty() {
1133 // Flatten newlines: gs prints `Error: ...` at column 0 mid-diagnostic,
1134 // which would inflate line-anchored ^Error: log counts.
1135 let flat = stderr_text.replace('\n', "; ");
1136 record_converter_diag(format!("`{prog}`: {flat}"));
1137 } else if status.map(|s| !s.success()).unwrap_or(true) {
1138 let how = match status {
1139 Some(s) => format!("exited {s}"),
1140 None => format!("timed out after {}s / killed", timeout.as_secs()),
1141 };
1142 record_converter_diag(format!("`{prog}`: {how}, no stderr"));
1143 }
1144 status
1145 }
1146
1147 /// The SVG viewport in pixels — `viewBox` extent, else the root
1148 /// `width`/`height` lengths. `None` when the geometry can't be recovered, so
1149 /// callers omit the dimension attributes entirely (a browser then sizes the
1150 /// image itself, which beats writing a wrong number).
1151 ///
1152 /// Delegates to `latexml_core::util::image`, which owns the SVG root-tag
1153 /// parsing for both the engine and this pass — Perl likewise keeps one
1154 /// `Util::Image` shared by `Core` and `Post`. The former local parser read
1155 /// only double-quoted attributes, matched `stroke-width` as `width`, and
1156 /// truncated units (`10cm` → 10 px); it also slurped the entire file to find
1157 /// a root tag that lives in its first few hundred bytes. Witness for the
1158 /// bounded, lossy read: 1307.4573 (xfig-pstex_t paper, multi-byte characters
1159 /// in the SVG preamble) — a FATAL_101 panic when a fixed 2048-byte slice cut
1160 /// a UTF-8 sequence.
1161 fn read_svg_dimensions(path: &str) -> Option<(u32, u32)> {
1162 latexml_core::util::image::read_svg_viewport_px(Path::new(path))
1163 }
1164
1165 /// Decide whether the vector-SVG path should be attempted for this PDF
1166 /// source.
1167 ///
1168 /// Two modes, in priority order:
1169 ///
1170 /// 1. **Explicit threshold** (`threshold_kb > 0`): legacy `--graphics-svg-threshold-kb N`
1171 /// behaviour — try SVG for PDFs at most `N` KB, regardless of content. Preserved for
1172 /// back-compat and as the manual override on canvases where the auto-detector misclassifies.
1173 /// 2. **Auto-detect default** (`threshold_kb == 0`): scan the PDF header for `/Subtype /Image`
1174 /// and `/Subtype/Image` (the two typical formattings of an image XObject declaration). If NONE
1175 /// is present in the first 256 KB and the file size is at most 500 KB → try SVG. This is the
1176 /// per-paper relief case documented in PERFORMANCE.md (130× speedup on the 41 KB pgfplots
1177 /// fixture).
1178 ///
1179 /// Both modes can be globally disabled via
1180 /// `LATEXML_GRAPHICS_VECTOR_AUTO_OFF=1` (auto-detect only — leaves
1181 /// the explicit-threshold path active).
1182 ///
1183 /// Safety net: any false positive falls back to the raster path when a
1184 /// vector converter emits >`MAX_SVG_OUTPUT_BYTES` (8 MB) of SVG, so a
1185 /// misread raster PDF degrades to "tried SVG, got too-big output, used
1186 /// convert" instead of a stuck pipeline.
1187 fn should_try_svg_path(source: &str, threshold_kb: u32) -> bool {
1188 if !source.to_lowercase().ends_with(".pdf") {
1189 return false;
1190 }
1191 if threshold_kb > 0 {
1192 // Legacy explicit-threshold path: bytes-only decision.
1193 return match std::fs::metadata(source) {
1194 Ok(md) => md.len() <= (threshold_kb as u64) * 1024,
1195 Err(_) => false,
1196 };
1197 }
1198 // Auto-detect path. Honour the opt-out.
1199 if Self::vector_auto_detect_disabled() {
1200 return false;
1201 }
1202 let len = match std::fs::metadata(source) {
1203 Ok(md) => md.len(),
1204 Err(_) => return false,
1205 };
1206 // Hard upper bound: even if the detector misses an image
1207 // somewhere deeper in the file, a 500 KB cap keeps the
1208 // worst-case wasted vector-conversion work bounded (~1-2 s before the
1209 // 8 MB output cap kicks in or the conversion finishes anyway).
1210 const AUTO_MAX_BYTES: u64 = 500 * 1024;
1211 if len > AUTO_MAX_BYTES {
1212 return false;
1213 }
1214 !Self::pdf_has_image_xobject(source).unwrap_or(true)
1215 }
1216
1217 /// Has `LATEXML_GRAPHICS_VECTOR_AUTO_OFF` been set? Memoised on
1218 /// first call so the env var is read once.
1219 fn vector_auto_detect_disabled() -> bool {
1220 use std::sync::OnceLock;
1221 static CELL: OnceLock<bool> = OnceLock::new();
1222 *CELL.get_or_init(|| {
1223 matches!(
1224 std::env::var("LATEXML_GRAPHICS_VECTOR_AUTO_OFF")
1225 .ok()
1226 .as_deref()
1227 .map(|s| s.trim()),
1228 Some("1") | Some("true") | Some("yes")
1229 )
1230 })
1231 }
1232
1233 /// Scan a PDF for `/Subtype /Image` (with or without whitespace
1234 /// between the tokens) — the canonical marker of an image XObject.
1235 /// Returns `Some(true)` when found, `Some(false)` when absent in the
1236 /// scanned range, `None` on I/O error.
1237 ///
1238 /// Reads at most `SCAN_LIMIT` bytes from the start of the file. PDF
1239 /// objects are written sequentially; for small files (≤500 KB,
1240 /// guarded by `should_try_svg_path`'s outer size check) the entire
1241 /// stream fits comfortably within the limit. Pure-vector PDFs scan
1242 /// in well under a millisecond on modern hardware.
1243 fn pdf_has_image_xobject(source: &str) -> Option<bool> {
1244 use std::io::Read;
1245 const SCAN_LIMIT: usize = 256 * 1024;
1246 let mut f = std::fs::File::open(source).ok()?;
1247 let mut buf = vec![0u8; SCAN_LIMIT];
1248 let n = f.read(&mut buf).ok()?;
1249 let head = &buf[..n];
1250 // Both spelling variants seen in the wild: `/Subtype /Image` (PDFs
1251 // from inkscape / cairo / latex+dvips) and `/Subtype/Image` (more
1252 // common in pdflatex output and ImageMagick-produced PDFs).
1253 Some(twoway_contains(head, b"/Subtype /Image") || twoway_contains(head, b"/Subtype/Image"))
1254 }
1255
1256 fn raster_density_for_source(source: &str) -> u32 {
1257 // `LATEXML_RASTER_DENSITY` overrides the default DPI globally for
1258 // benchmarking and quality/perf tradeoff exploration. Clamped to
1259 // [50, 600]. Unset → default (matches Perl `Util/Image.pm` 100).
1260 let base = std::env::var("LATEXML_RASTER_DENSITY")
1261 .ok()
1262 .and_then(|s| s.parse::<u32>().ok())
1263 .map(|d| d.clamp(50, 600))
1264 .unwrap_or(Self::DEFAULT_RASTER_DENSITY);
1265 let source_lc = source.to_lowercase();
1266 let is_postscript = source_lc.ends_with(".eps")
1267 || source_lc.ends_with(".epsi")
1268 || source_lc.ends_with(".epsf")
1269 || source_lc.ends_with(".ps")
1270 || source_lc.ends_with(".ai");
1271 let is_pdf = source_lc.ends_with(".pdf");
1272 let page_box = if is_postscript {
1273 read_postscript_bounding_box(source)
1274 } else if is_pdf {
1275 latexml_core::util::image::read_pdf_page_box(Path::new(source))
1276 } else {
1277 None
1278 };
1279 let Some((w_pt, h_pt)) = page_box else {
1280 return base;
1281 };
1282 let max_pt = w_pt.max(h_pt);
1283 if max_pt <= 0.0 {
1284 return base;
1285 }
1286
1287 let max_density = ((Self::MAX_RASTER_DIMENSION_PX as f64) * 72.0 / max_pt).floor() as u32;
1288 base.min(max_density.max(1))
1289 }
1290
1291 /// Returns true when the PS / EPS file's DSC header declares
1292 /// `%%Orientation: Landscape`. PGPLOT and a handful of older
1293 /// scientific renderers emit landscape PS files with a portrait
1294 /// `%%BoundingBox` — content is drawn rotated 90° on the page, and
1295 /// PS-level renderers (gs, IM) ignore the Orientation hint, producing
1296 /// visibly upside-down output. ps2pdf is the one tool in the chain
1297 /// that honors the comment by writing `/Rotate 90` into the PDF
1298 /// header; pdftocairo then renders the rotated PDF correctly.
1299 ///
1300 /// Witness: astro-ph0103041 NickMorgan.fig2.ps. Only the first ~80
1301 /// lines of the DSC prologue are scanned because all conforming PS
1302 /// files emit `%%Orientation:` early.
1303 fn postscript_is_landscape(source: &str) -> bool {
1304 let Ok(file) = std::fs::File::open(source) else {
1305 return false;
1306 };
1307 use std::io::BufRead;
1308 let reader = std::io::BufReader::new(file);
1309 for line in reader.lines().take(80).map_while(Result::ok) {
1310 if let Some(rest) = line.strip_prefix("%%Orientation:") {
1311 return rest.trim().eq_ignore_ascii_case("Landscape");
1312 }
1313 }
1314 false
1315 }
1316
1317 fn should_try_eps_pdf_path(source: &str, page: Option<u32>) -> bool {
1318 // DISABLED 2026-05-12 after 1303.5091 regression.
1319 //
1320 // The `ps2pdf -dEPSCrop` step injects `/Rotate N` PDF annotations
1321 // based on EPS internal orientation hints. PDF's `/Rotate` is
1322 // CLOCKWISE (per PDF spec), but graphicx `angle=N` is
1323 // COUNTER-CLOCKWISE — these are OPPOSITE conventions. After
1324 // pdftocairo respects /Rotate, applying our `angle` rotation on
1325 // top yields content that's 180° off (upside-down).
1326 //
1327 // Perl LaTeXML doesn't use ps2pdf — it uses ImageMagick `convert`
1328 // (which spawns Ghostscript via the EPS delegate) directly, and
1329 // ImageMagick's Rotate takes degrees in CCW = matches graphicx.
1330 //
1331 // Match that: route EPS through the `convert` path, which is
1332 // slower than ps2pdf+pdftocairo but produces correctly-oriented
1333 // output. The performance hit only affects EPS-source documents
1334 // (rare in the canvas — PDF dominates modern arXiv).
1335 let _ = (source, page);
1336 false
1337 }
1338
1339 /// Whether to attempt the poppler `pdftocairo --png` fast-path for a PDF
1340 /// source. The page argument cooperates with pdftocairo's 1-based
1341 /// `-f`/`-l` page selector. Empirical: for vector-heavy PDFs (e.g.
1342 /// R-Graphics output) `pdftocairo` rasterizes 25× faster than
1343 /// ImageMagick-via-Ghostscript and produces a clean PNG, where the
1344 /// vector-SVG path explodes to >100 MB and `convert`/`gs` runs into
1345 /// tens of seconds on a single page.
1346 fn should_try_pdf_cairo_path(source: &str) -> bool { source.to_lowercase().ends_with(".pdf") }
1347
1348 /// Rasterize a PDF via the `mutool draw` subprocess (MuPDF CLI).
1349 /// ~1.7× faster than `pdftocairo` on vector-heavy matplotlib /
1350 /// pgfplots scatter PDFs (the canvas slow-tail).
1351 ///
1352 /// Subprocess invocation only — we do NOT link the MuPDF C library
1353 /// or the `mupdf-rs` Rust crate into our binary, so MuPDF's AGPL-3.0
1354 /// license does not propagate. Same legal pattern as invoking
1355 /// `/bin/git` or `ffmpeg` from a non-GPL program.
1356 ///
1357 /// Measured 2026-05-12 on AugmentedMSRA10KExperimentVIIIpos.pdf
1358 /// (894 KB matplotlib scatter):
1359 /// mutool draw: 0.48 s
1360 /// pdftocairo: 0.86 s
1361 ///
1362 /// Returns true only when the destination file was actually written.
1363 /// Optional dep: graceful fallthrough when `mutool` is not on PATH.
1364 fn convert_pdf_via_mutool(source: &str, dest: &str, density: u32, page: Option<u32>) -> bool {
1365 let dest_path = Path::new(dest);
1366 let parent = dest_path.parent().unwrap_or_else(|| Path::new("."));
1367 let stem = dest_path
1368 .file_name()
1369 .and_then(|s| s.to_str())
1370 .unwrap_or("image");
1371 let unique = std::time::SystemTime::now()
1372 .duration_since(std::time::UNIX_EPOCH)
1373 .map(|d| d.as_nanos())
1374 .unwrap_or(0);
1375 let tmp = parent.join(format!(".{}.{}.mutool.png", stem, unique));
1376 let timeout = std::time::Duration::from_secs(20);
1377
1378 let mut cmd = std::process::Command::new("mutool");
1379 cmd
1380 .arg("draw")
1381 .arg("-o")
1382 .arg(&tmp)
1383 .arg("-r")
1384 .arg(density.to_string())
1385 .arg("-F")
1386 .arg("png");
1387 let p1 = page.map(|p| p.max(1)).unwrap_or(1);
1388 cmd.arg(source).arg(p1.to_string());
1389
1390 let mutool_ok = Self::run_with_timeout(cmd, timeout)
1391 .map(|status| status.success())
1392 .unwrap_or(false)
1393 && tmp.exists();
1394 if !mutool_ok {
1395 let _ = std::fs::remove_file(&tmp);
1396 return false;
1397 }
1398
1399 let _ = std::fs::remove_file(dest);
1400 let installed = std::fs::rename(&tmp, dest)
1401 .or_else(|_| std::fs::copy(&tmp, dest).map(|_| ()))
1402 .is_ok()
1403 && dest_path.exists();
1404 let _ = std::fs::remove_file(&tmp);
1405 installed
1406 }
1407
1408 /// Rasterize a PDF directly via `pdftocairo --png`. Much faster than
1409 /// `convert`/Ghostscript for vector-heavy PDFs. Returns true only when
1410 /// the destination file was actually written.
1411 fn convert_pdf_via_pdftocairo(source: &str, dest: &str, density: u32, page: Option<u32>) -> bool {
1412 let dest_path = Path::new(dest);
1413 let parent = dest_path.parent().unwrap_or_else(|| Path::new("."));
1414 let stem = dest_path
1415 .file_name()
1416 .and_then(|s| s.to_str())
1417 .unwrap_or("image");
1418 let unique = std::time::SystemTime::now()
1419 .duration_since(std::time::UNIX_EPOCH)
1420 .map(|d| d.as_nanos())
1421 .unwrap_or(0);
1422 let tmp_prefix = parent.join(format!(".{}.{}.pdftocairo", stem, unique));
1423 let tmp_png = PathBuf::from(format!("{}.png", tmp_prefix.to_string_lossy()));
1424 let timeout = std::time::Duration::from_secs(20);
1425
1426 let cleanup = |tmp_png: &Path| {
1427 let _ = std::fs::remove_file(tmp_png);
1428 };
1429
1430 let mut pdftocairo = std::process::Command::new("pdftocairo");
1431 pdftocairo
1432 .arg("-singlefile")
1433 .arg("-png")
1434 .arg("-r")
1435 .arg(density.to_string());
1436 // graphicx page is 1-based; pdftocairo also uses 1-based.
1437 if let Some(p) = page {
1438 let p1 = p.max(1);
1439 pdftocairo
1440 .arg("-f")
1441 .arg(p1.to_string())
1442 .arg("-l")
1443 .arg(p1.to_string());
1444 } else {
1445 // Default to first page (matches Perl/`convert` `[0]` behavior).
1446 pdftocairo.arg("-f").arg("1").arg("-l").arg("1");
1447 }
1448 pdftocairo.arg(source).arg(&tmp_prefix);
1449
1450 let cairo_ok = Self::run_with_timeout(pdftocairo, timeout)
1451 .map(|status| status.success())
1452 .unwrap_or(false)
1453 && tmp_png.exists();
1454 if !cairo_ok {
1455 cleanup(&tmp_png);
1456 return false;
1457 }
1458
1459 let _ = std::fs::remove_file(dest);
1460 let installed = std::fs::rename(&tmp_png, dest)
1461 .or_else(|_| std::fs::copy(&tmp_png, dest).map(|_| ()))
1462 .is_ok()
1463 && dest_path.exists();
1464 cleanup(&tmp_png);
1465 installed
1466 }
1467
1468 /// Some EPS files make ImageMagick/Ghostscript spend tens of seconds in
1469 /// direct rasterization. Converting EPS to a cropped PDF first and then
1470 /// rasterizing the PDF via poppler is much faster for those cases, while
1471 /// still falling back to ImageMagick if either helper is unavailable.
1472 fn convert_eps_via_pdf(source: &str, dest: &str, density: u32) -> bool {
1473 let dest_path = Path::new(dest);
1474 let parent = dest_path.parent().unwrap_or_else(|| Path::new("."));
1475 let stem = dest_path
1476 .file_name()
1477 .and_then(|s| s.to_str())
1478 .unwrap_or("image");
1479 let unique = std::time::SystemTime::now()
1480 .duration_since(std::time::UNIX_EPOCH)
1481 .map(|d| d.as_nanos())
1482 .unwrap_or(0);
1483 let tmp_pdf = parent.join(format!(".{}.{}.pdf", stem, unique));
1484 let tmp_prefix = parent.join(format!(".{}.{}.pdftocairo", stem, unique));
1485 let tmp_png = PathBuf::from(format!("{}.png", tmp_prefix.to_string_lossy()));
1486 let timeout = std::time::Duration::from_secs(20);
1487
1488 let cleanup = |tmp_pdf: &Path, tmp_png: &Path| {
1489 let _ = std::fs::remove_file(tmp_pdf);
1490 let _ = std::fs::remove_file(tmp_png);
1491 };
1492
1493 let mut ps2pdf = std::process::Command::new("ps2pdf");
1494 ps2pdf.arg("-dEPSCrop").arg(source).arg(&tmp_pdf);
1495 let ps2pdf_ok = Self::run_with_timeout(ps2pdf, timeout)
1496 .map(|status| status.success())
1497 .unwrap_or(false)
1498 && tmp_pdf.exists();
1499 if !ps2pdf_ok {
1500 cleanup(&tmp_pdf, &tmp_png);
1501 return false;
1502 }
1503
1504 let mut pdftocairo = std::process::Command::new("pdftocairo");
1505 pdftocairo
1506 .arg("-singlefile")
1507 .arg("-png")
1508 .arg("-r")
1509 .arg(density.to_string())
1510 .arg(&tmp_pdf)
1511 .arg(&tmp_prefix);
1512 let cairo_ok = Self::run_with_timeout(pdftocairo, timeout)
1513 .map(|status| status.success())
1514 .unwrap_or(false)
1515 && tmp_png.exists();
1516 if !cairo_ok {
1517 cleanup(&tmp_pdf, &tmp_png);
1518 return false;
1519 }
1520
1521 let _ = std::fs::remove_file(dest);
1522 let installed = std::fs::rename(&tmp_png, dest)
1523 .or_else(|_| std::fs::copy(&tmp_png, dest).map(|_| ()))
1524 .is_ok()
1525 && dest_path.exists();
1526 cleanup(&tmp_pdf, &tmp_png);
1527 installed
1528 }
1529
1530 /// Direct Ghostscript rasterization for EPS/PS sources, bypassing
1531 /// ImageMagick's wrapper.
1532 ///
1533 /// `convert` for EPS already shells out to `gs` internally, so by
1534 /// invoking `gs` ourselves we save the IM read-pipeline overhead
1535 /// (~50-200 ms per image on the canvas). gs's `Rotate` direction is
1536 /// CCW — same as graphicx and IM — so this matches the Perl
1537 /// `image_graphicx_complex` semantics exactly. No /Rotate metadata
1538 /// is produced (gs writes PNG/JPG directly), so we don't inherit
1539 /// the rotation-mismatch bug from the disabled ps2pdf path.
1540 fn convert_eps_via_gs(source: &str, dest: &str, density: u32) -> bool {
1541 let dest_path = Path::new(dest);
1542 let parent = dest_path.parent().unwrap_or_else(|| Path::new("."));
1543 let stem = dest_path
1544 .file_name()
1545 .and_then(|s| s.to_str())
1546 .unwrap_or("image");
1547 let unique = std::time::SystemTime::now()
1548 .duration_since(std::time::UNIX_EPOCH)
1549 .map(|d| d.as_nanos())
1550 .unwrap_or(0);
1551 // gs picks its output extension from the device, not the path, so
1552 // we pass it whatever name and rename atomically at the end.
1553 let tmp = parent.join(format!(".{}.{}.gs", stem, unique));
1554 let timeout = std::time::Duration::from_secs(30);
1555 let dest_lc = dest.to_lowercase();
1556 // pngalpha matches IM's `ps:alpha` delegate (the default EPS→PNG
1557 // path). It produces an RGBA PNG where blank canvas is transparent
1558 // — important for plot backgrounds matching document background.
1559 // png16m would force a white background regardless of source.
1560 let device = if dest_lc.ends_with(".jpg") || dest_lc.ends_with(".jpeg") {
1561 "jpeg"
1562 } else {
1563 "pngalpha"
1564 };
1565
1566 let mut cmd = std::process::Command::new(gs_program());
1567 cmd
1568 .arg("-q")
1569 .arg("-dNOPAUSE")
1570 .arg("-dBATCH")
1571 .arg("-dSAFER")
1572 // Antialiasing — IM passes these through its delegate by
1573 // default. Without them gs produces aliased, jagged output
1574 // that's visibly worse than `convert`. 4 = max quality;
1575 // 2 = balanced; 1 = off. Matches IM's delegate.xml defaults.
1576 .arg("-dTextAlphaBits=4")
1577 .arg("-dGraphicsAlphaBits=4")
1578 // Render the entire page in memory rather than band-by-band.
1579 // Eliminates seam artifacts on large pages. Mirrors IM's
1580 // delegate.xml: -dMaxBitmap=500000000.
1581 .arg("-dMaxBitmap=500000000")
1582 .arg("-dAlignToPixels=0")
1583 .arg("-dGridFitTT=2")
1584 // -dEPSCrop here means "honor the EPS BoundingBox when rendering"
1585 // (a gs rendering flag), NOT the ps2pdf flag that injected
1586 // /Rotate in the earlier disabled path. gs writing PNG never
1587 // produces PDF metadata, so this is safe.
1588 .arg("-dEPSCrop");
1589 // When the EPS declares a `%%BoundingBox`, force the device
1590 // page-size to match it AND lock it via `-dFIXEDMEDIA`. Some EPS
1591 // files (notably `pswrite`-output, e.g. AFPL Ghostscript-generated
1592 // figures like astro-ph0503029/figure7.eps) embed their own
1593 // `setpagedevice` calls that override `-dEPSCrop` and force a full
1594 // US-Letter page (612 × 792 pt), so the content lands at the bottom
1595 // of a 1020 × 1320 px canvas with a 968-pixel blank above it.
1596 // `-dFIXEDMEDIA` makes gs ignore the embedded `setpagedevice` and
1597 // honour our explicit dimensions. Witness: astro-ph0503029 fig 7.
1598 //
1599 // When the BoundingBox is offset from origin (e.g.
1600 // `%%BoundingBox: 117 242 524 567` in hep-ph0608319/data6.ps),
1601 // FIXED page-size alone isn't enough — content drawn at PS
1602 // (117, 242) lands OUTSIDE a (407, 325) page. Translate via
1603 // PostScript `-c "<x0_neg> <y0_neg> translate"` BEFORE the EPS
1604 // file is interpreted, shifting the content to origin (0, 0).
1605 // PS `-c` snippet executes after the device init but before the
1606 // file load. Witness: hep-ph0608319/data6.ps (`(atend)` header,
1607 // real BBox `117 242 524 567`) — without translate the rendered
1608 // 992 × 1403 letter page has a tiny content blob; with translate
1609 // we get a tight 407 × 325 pt crop matching what convert produces.
1610 // Device init flags must precede `-c` / `-f` because gs `-f`
1611 // takes the NEXT argument as a file to interpret; anything after
1612 // `-f` is no longer parsed as an option.
1613 cmd
1614 .arg(format!("-sDEVICE={}", device))
1615 .arg(format!("-r{}", density))
1616 .arg(format!("-sOutputFile={}", tmp.display()));
1617 let bbox_full = read_postscript_bounding_box_full(source);
1618 if let Some((x0, y0, w_pt, h_pt)) = bbox_full {
1619 let w = w_pt.max(1.0).ceil() as u32;
1620 let h = h_pt.max(1.0).ceil() as u32;
1621 cmd
1622 .arg("-dFIXEDMEDIA")
1623 .arg(format!("-dDEVICEWIDTHPOINTS={}", w))
1624 .arg(format!("-dDEVICEHEIGHTPOINTS={}", h));
1625 if x0.abs() > 0.5 || y0.abs() > 0.5 {
1626 cmd
1627 .arg("-c")
1628 .arg(format!("{} {} translate", -x0, -y0))
1629 .arg("-f");
1630 }
1631 }
1632 cmd.arg(source);
1633 let gs_ok = Self::run_with_timeout(cmd, timeout)
1634 .map(|s| s.success())
1635 .unwrap_or(false)
1636 && tmp.exists();
1637 if !gs_ok {
1638 let _ = std::fs::remove_file(&tmp);
1639 return false;
1640 }
1641 let _ = std::fs::remove_file(dest);
1642 let installed = std::fs::rename(&tmp, dest)
1643 .or_else(|_| std::fs::copy(&tmp, dest).map(|_| ()))
1644 .is_ok()
1645 && dest_path.exists();
1646 let _ = std::fs::remove_file(&tmp);
1647 installed
1648 }
1649
1650 /// Convert a graphics file using ImageMagick's `convert` command.
1651 /// Perl: image_graphicx_complex via Image::Magick / convert CLI.
1652 /// `page` is 1-based (graphicx convention); converted to 0-based for ImageMagick.
1653 fn convert_image(source: &str, dest: &str, _dpi: u32, page: Option<u32>) -> bool {
1654 // Build the source argument with optional page selector
1655 // Perl: image_read reads "$source[$page]" where $page = ($page // 1) - 1
1656 let source_arg = if let Some(p) = page {
1657 format!("{}[{}]", source, p.saturating_sub(1))
1658 } else {
1659 // No page specified: use [0] for PDFs to avoid converting all pages
1660 if source.to_lowercase().ends_with(".pdf") {
1661 format!("{}[0]", source)
1662 } else {
1663 source.to_string()
1664 }
1665 };
1666 // Shell out to convert (matching Perl's approach)
1667 // -define pdf:use-cropbox=true matches Perl's Image::Magick option (line 466)
1668 let density = Self::raster_density_for_source(source);
1669 if Self::should_try_eps_pdf_path(source, page)
1670 && Self::convert_eps_via_pdf(source, dest, density)
1671 {
1672 return true;
1673 }
1674 // Fast EPS/PS path: skip the ImageMagick wrapper and call `gs`
1675 // directly. Same renderer, same CCW Rotate convention; ~50-200 ms
1676 // saved per image. Falls through to `convert` on any failure.
1677 // Only attempted when no page selector is present (EPS is
1678 // single-page; PS multi-page handled by `convert`'s `[N]` syntax).
1679 //
1680 // EXCEPT: when the PS file declares `%%Orientation: Landscape` in
1681 // its header comments — typical of PGPLOT output, e.g.
1682 // astro-ph0103041 NickMorgan.fig2.ps. Direct gs / convert ignore
1683 // the Orientation comment and render at literal portrait BBox
1684 // coordinates, producing visibly upside-down output. ps2pdf
1685 // honors %%Orientation and writes `/Rotate 90` into the resulting
1686 // PDF; pdftocairo then honors the PDF rotation and emits
1687 // correctly-oriented landscape pixels. Route those through the
1688 // pdf-intermediate path. The earlier disabled `should_try_eps_pdf_path`
1689 // was about an ORTHOGONAL bug (graphicx angle= compounding with
1690 // ps2pdf-injected /Rotate on portrait files) — the 1303.5091 EPS
1691 // files have NO `%%Orientation:` comment, so they go through the
1692 // gs path as before.
1693 let src_lc = source.to_lowercase();
1694 let is_postscript = src_lc.ends_with(".eps")
1695 || src_lc.ends_with(".epsi")
1696 || src_lc.ends_with(".epsf")
1697 || src_lc.ends_with(".ps");
1698 if is_postscript && page.is_none() {
1699 if Self::postscript_is_landscape(source) && Self::convert_eps_via_pdf(source, dest, density) {
1700 return true;
1701 }
1702 if Self::convert_eps_via_gs(source, dest, density) {
1703 return true;
1704 }
1705 }
1706 // For PDF sources, try fast subprocess rasterizers in measured-
1707 // speed order. Subprocess (not linked) so library license doesn't
1708 // propagate — same legal pattern as invoking `git` or `ffmpeg`.
1709 // In-process Rust crates were evaluated 2026-05-12 and rejected
1710 // (mupdf-rs AGPL, poppler-rs GPL, pdfium-render single-threaded).
1711 //
1712 // 1. mutool (MuPDF CLI) — ~1.7× faster than pdftocairo on the canvas slow-tail
1713 // (matplotlib/pgfplots scatter PDFs).
1714 // 2. pdftocairo (poppler) — universally available with TeX Live; 25× faster than convert/gs.
1715 // 3. convert/gs — last-resort, hard-timeout-bounded.
1716 if Self::should_try_pdf_cairo_path(source) && dest.to_lowercase().ends_with(".png") {
1717 if Self::convert_pdf_via_mutool(source, dest, density, page) {
1718 return true;
1719 }
1720 if Self::convert_pdf_via_pdftocairo(source, dest, density, page) {
1721 return true;
1722 }
1723 }
1724 // Wall-clock timeout to bound `gs`-via-`convert` runaways on
1725 // pathological PDFs (raster-heavy or with broken xref tables).
1726 // Matches the vector-SVG path's defensive bound; without this, an
1727 // arbitrary `convert` invocation could run for minutes and stall
1728 // the entire post-processing phase. 60 s is enough for any
1729 // reasonably-sized graphic; tune via `LATEXML_CONVERT_TIMEOUT_SECS`.
1730 //
1731 // Crucially: `run_with_timeout` puts convert in its own process
1732 // group via setsid+pre_exec (Unix), so killing convert on timeout
1733 // also kills the gs grandchild. Without that, gs orphaned by a
1734 // dying convert kept running 10+ min and stalled the sandbox.
1735 let mut cmd = std::process::Command::new(im_convert_program());
1736 cmd
1737 .arg("-define")
1738 .arg("pdf:use-cropbox=true")
1739 .arg("-density")
1740 .arg(density.to_string())
1741 .arg(&source_arg)
1742 .arg(dest);
1743 let timeout = std::time::Duration::from_secs(Self::convert_timeout_secs());
1744 match Self::run_with_timeout(cmd, timeout) {
1745 Some(status) => {
1746 // A clean exit is NOT sufficient: ImageMagick `convert` (and the `gs`
1747 // it drives) exits 0 on some corrupt/unrenderable inputs while writing
1748 // no file. Require an actual non-empty output so a genuine "no image"
1749 // failure surfaces (returns false → the caller's imageprocessing
1750 // Error) instead of being mistaken for success and emitting a
1751 // broken/empty <img>. The fast PDF/EPS paths already verify their
1752 // output (mutool/pdftocairo check `dest.exists()`); this brings the
1753 // final `convert` fallback in line.
1754 if status.success() && Self::produced_output(dest) {
1755 true
1756 } else {
1757 let _ = std::fs::remove_file(dest); // drop any partial/empty output
1758 false
1759 }
1760 },
1761 None => {
1762 Warn!(
1763 "shell",
1764 "convert",
1765 "Graphics: convert/gs for {} exceeded {} s — killed",
1766 source,
1767 timeout.as_secs()
1768 );
1769 let _ = std::fs::remove_file(dest);
1770 false
1771 },
1772 }
1773 }
1774
1775 /// True iff `dest` was actually written as a usable (non-empty) file. Used to
1776 /// validate a subprocess conversion whose exit code alone is unreliable
1777 /// (`convert`/`gs` can exit 0 having produced nothing). A 0-byte file counts
1778 /// as failure — it would render as a broken image.
1779 fn produced_output(dest: &str) -> bool {
1780 std::fs::metadata(dest)
1781 .map(|m| m.len() > 0)
1782 .unwrap_or(false)
1783 }
1784
1785 /// Hard timeout (seconds) for the `convert` subprocess. Mirrors
1786 /// `svg_convert_timeout_secs`; default 60 s. Override via
1787 /// `LATEXML_CONVERT_TIMEOUT_SECS` for debugging.
1788 fn convert_timeout_secs() -> u64 { CONVERT_TIMEOUT_SECS.unwrap_or(60) }
1789}
1790
1791/// Byte-substring search. Used by `pdf_has_image_xobject` to scan a
1792/// PDF prefix for the `/Subtype /Image` marker. Linear in `hay.len()`
1793/// times `needle.len()`; both bounded so adequate.
1794fn twoway_contains(hay: &[u8], needle: &[u8]) -> bool {
1795 if needle.is_empty() || needle.len() > hay.len() {
1796 return false;
1797 }
1798 hay.windows(needle.len()).any(|w| w == needle)
1799}
1800
1801/// Map a destination path to a `&'static str` extension suitable for
1802/// `graphics_cache::RenderKey::ext`. Only common image targets need to
1803/// round-trip through the cache; anything else collapses to `""` and
1804/// still keys correctly (the cache file simply lacks an extension).
1805fn ext_from_path(path: &str) -> &'static str {
1806 let lower = path.rsplit('/').next().unwrap_or(path);
1807 if let Some(idx) = lower.rfind('.') {
1808 let tail = &lower[idx + 1..];
1809 if tail.eq_ignore_ascii_case("png") {
1810 "png"
1811 } else if tail.eq_ignore_ascii_case("svg") {
1812 "svg"
1813 } else if tail.eq_ignore_ascii_case("jpg") || tail.eq_ignore_ascii_case("jpeg") {
1814 "jpg"
1815 } else if tail.eq_ignore_ascii_case("gif") {
1816 "gif"
1817 } else if tail.eq_ignore_ascii_case("webp") {
1818 "webp"
1819 } else {
1820 ""
1821 }
1822 } else {
1823 ""
1824 }
1825}
1826
1827/// Returns the EPS BoundingBox as `(x0, y0, w, h)` where (x0, y0) is
1828/// the lower-left corner of the content in PS coords and (w, h) is
1829/// the content extent. Callers needing only the extent can ignore the
1830/// origin via `_`-destructuring or `.map(|(_, _, w, h)| (w, h))`.
1831///
1832/// Handles three DSC variants:
1833/// 1. `%%BoundingBox: x0 y0 x1 y1` in the header (most files).
1834/// 2. `%%BoundingBox: (atend)` in the header, real values in the Trailer at end-of-file (some
1835/// HIGZ, PAW, certain pswrite output).
1836/// 3. `%%HiResBoundingBox: x0.x y0.y x1.x y1.y` — used when literal `%%BoundingBox:` is missing.
1837fn read_postscript_bounding_box_full(source: &str) -> Option<(f64, f64, f64, f64)> {
1838 let content = std::fs::read_to_string(source).ok()?;
1839 let mut header_lines = content.lines().take(80);
1840 let mut atend = false;
1841 let mut hi_res: Option<(f64, f64, f64, f64)> = None;
1842 for line in &mut header_lines {
1843 if let Some(rest) = line.strip_prefix("%%BoundingBox:") {
1844 let rest_trim = rest.trim();
1845 if rest_trim.eq_ignore_ascii_case("(atend)") {
1846 atend = true;
1847 continue;
1848 }
1849 if let Some(b) = parse_bbox_quadruple(rest) {
1850 return Some(b);
1851 }
1852 } else if let Some(rest) = line.strip_prefix("%%HiResBoundingBox:") {
1853 hi_res = hi_res.or_else(|| parse_bbox_quadruple(rest));
1854 }
1855 }
1856 if atend {
1857 // Scan the last ~80 lines for a Trailer-section BoundingBox.
1858 let tail: Vec<&str> = content.lines().rev().take(80).collect();
1859 for line in tail {
1860 if let Some(rest) = line.strip_prefix("%%BoundingBox:") {
1861 let rest_trim = rest.trim();
1862 if rest_trim.eq_ignore_ascii_case("(atend)") {
1863 continue;
1864 }
1865 if let Some(b) = parse_bbox_quadruple(rest) {
1866 return Some(b);
1867 }
1868 }
1869 }
1870 }
1871 hi_res
1872}
1873
1874fn parse_bbox_quadruple(s: &str) -> Option<(f64, f64, f64, f64)> {
1875 let mut vals = s.split_whitespace().filter_map(|s| s.parse::<f64>().ok());
1876 let (Some(x0), Some(y0), Some(x1), Some(y1)) =
1877 (vals.next(), vals.next(), vals.next(), vals.next())
1878 else {
1879 return None;
1880 };
1881 let w = (x1 - x0).abs();
1882 let h = (y1 - y0).abs();
1883 Some((x0, y0, w, h))
1884}
1885
1886/// Legacy width/height-only accessor for callers that don't need the
1887/// origin offset. Use `read_postscript_bounding_box_full` when you
1888/// need to translate the content to PS origin (0, 0).
1889fn read_postscript_bounding_box(source: &str) -> Option<(f64, f64)> {
1890 read_postscript_bounding_box_full(source).map(|(_, _, w, h)| (w, h))
1891}
1892
1893impl Processor for Graphics {
1894 fn get_name(&self) -> &str { &self.name }
1895
1896 fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
1897 doc.findnodes("//ltx:graphics[not(@imagesrc)]")
1898 }
1899
1900 fn process(&mut self, doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
1901 let mut search_paths = self.find_graphics_paths(&doc);
1902 search_paths.extend(doc.get_search_paths().iter().cloned());
1903 // Also add source directory
1904 let source_dir = doc.get_source_directory().to_string();
1905 if !source_dir.is_empty() && !search_paths.contains(&source_dir) {
1906 search_paths.push(source_dir.clone());
1907 }
1908 // Add current directory as fallback
1909 if !search_paths.contains(&".".to_string()) {
1910 search_paths.push(".".to_string());
1911 }
1912
1913 let dest_dir = doc.get_destination_directory().unwrap_or(".").to_string();
1914 // Read DPI/magnify/zoomout from processing instructions (set by latexml.sty)
1915 let dpi = self
1916 .get_parameter(&doc, "DPI")
1917 .map(|v| v as u32)
1918 .or(self.dpi)
1919 .unwrap_or(100);
1920 let magnify = self.get_parameter(&doc, "magnify").unwrap_or(self.magnify);
1921 let _zoomout = self.get_parameter(&doc, "zoomout").unwrap_or(self.zoomout);
1922 // Perl: effective DPI = DPI * magnify / zoomout (used for scale-to transforms)
1923 let effective_dpi = ((dpi as f64) * magnify / _zoomout) as u32;
1924 let n_to_process = nodes.len();
1925 latexml_core::telemetry::set_graphics_assets(n_to_process as u32);
1926
1927 // Counter for generating unique resource names (like Perl's generateResourcePathname)
1928 let mut resource_counter: u32 = 0;
1929
1930 // Two-phase plan so the slow per-image `convert` subprocess and
1931 // `read_image_dimensions` calls can run in parallel without touching
1932 // the libxml DOM off-thread.
1933 //
1934 // Phase 1 (serial): read each node's attributes, resolve source path,
1935 // decide the conversion kind, and allocate resource-name counters.
1936 // - `Plan::NotFound` — apply fallback on the main thread later
1937 // - `Plan::Copy { .. }` — apply on the main thread later (cheap)
1938 // - `Plan::Convert { .. }` — independent; run in parallel.
1939 // Phase 2 (parallel): run convert_image + read_image_dimensions for
1940 // `Plan::Convert` entries. Produces `JobResult`s keyed by node index.
1941 // Phase 3 (serial): apply DOM mutations on the main thread in original
1942 // node order so attribute writes happen on the libxml-owning thread.
1943 enum Plan {
1944 NotFound {
1945 idx: usize,
1946 graphic: String,
1947 },
1948 Copy {
1949 idx: usize,
1950 source: String,
1951 options: String,
1952 },
1953 Convert {
1954 idx: usize,
1955 options: String,
1956 job_id: usize,
1957 },
1958 }
1959 struct ConvertJob {
1960 job_id: usize,
1961 source: String,
1962 page: Option<u32>,
1963 rel_dest: String,
1964 abs_dest_str: String,
1965 /// `Some((rel_svg, abs_svg_str))` when the worker should
1966 /// first attempt the vector-SVG path and only fall back
1967 /// to the raster `convert` path on failure. `None` means
1968 /// the classic raster-only path.
1969 svg_paths: Option<(String, String)>,
1970 }
1971 struct ConvertOutcome {
1972 job_id: usize,
1973 /// Path to write into `imagesrc`; `None` if both convert and copy-fallback failed.
1974 imagesrc: Option<String>,
1975 /// Raw (pre-transform) dimensions read from whichever file we ended up with.
1976 raw_dims: Option<(u32, u32)>,
1977 }
1978
1979 let mut plans: Vec<Plan> = Vec::with_capacity(n_to_process);
1980 let mut convert_jobs: Vec<ConvertJob> = Vec::new();
1981 // Dedup key uses content-hash when readable, else falls back to
1982 // (source-path, page, options). Two byte-identical files with the
1983 // same options share one conversion + one output bundle entry.
1984 // The first-seen source's stem names the dest. Both <img> tags
1985 // end up pointing to that same rel_dest.
1986 #[derive(Hash, Eq, PartialEq)]
1987 enum JobKey {
1988 Hashed(u64, Option<u32>, String),
1989 Pathy(String, Option<u32>, String),
1990 }
1991 let mut convert_job_ids: HashMap<JobKey, usize> = HashMap::default();
1992 // Plan::Copy uses the same (hash, options) dedup so byte-identical
1993 // raster sources point at one output. `options` is part of the key
1994 // because angle= mutates the dest in-place — different rotations of
1995 // the same source need different dest files.
1996 #[derive(Hash, Eq, PartialEq)]
1997 enum CopyKey {
1998 Hashed(u64, String),
1999 Pathy(String, String),
2000 }
2001 let mut copy_dedup: HashMap<CopyKey, String> = HashMap::default();
2002 let mut convert_source_counts: HashMap<String, u32> = HashMap::default();
2003 for (idx, node) in nodes.iter().enumerate() {
2004 let options = node.get_attribute("options").unwrap_or_default();
2005 let page = Self::parse_page_option(&options);
2006 let Some(source) = self.find_graphic_file(&doc, node, &search_paths) else {
2007 let graphic = node
2008 .get_attribute("graphic")
2009 .unwrap_or_else(|| "none".to_string());
2010 plans.push(Plan::NotFound { idx, graphic });
2011 continue;
2012 };
2013 let src_ext = Path::new(&source)
2014 .extension()
2015 .and_then(|e| e.to_str())
2016 .unwrap_or("")
2017 .to_lowercase();
2018 let props = self.type_properties.get(&src_ext).cloned();
2019 // Robustness guard (surpass-Perl; user directive 2026-06-22). A source
2020 // type with no explicit `destination_type` defaults to a WEB-NATIVE
2021 // target: keep web-native sources (svg/png/gif/jpg/jpeg) as-is, but
2022 // rasterize anything else to `png`. This guarantees a non-web-native
2023 // source is ALWAYS routed through Plan::Convert and never Plan::Copy'd
2024 // verbatim — closing the hole for the unmapped `.postscript` graphics
2025 // type (and any future addition) where a raw .ps/.eps/.pdf/.ai could
2026 // otherwise reach the web. Perl defaults dest_type to srctype here
2027 // (Graphics.pm:244, `$type = $properties{destination_type} || $srctype`),
2028 // which would copy such a source raw; we deliberately diverge so the web
2029 // output is never a raw .eps/.ps/.pdf the browser can't render.
2030 const WEB_NATIVE: &[&str] = &["svg", "png", "gif", "jpg", "jpeg"];
2031 let dest_type = props
2032 .as_ref()
2033 .and_then(|p| p.destination_type.as_ref())
2034 .cloned()
2035 .unwrap_or_else(|| {
2036 if WEB_NATIVE.contains(&src_ext.as_str()) {
2037 src_ext.clone()
2038 } else {
2039 "png".to_string()
2040 }
2041 });
2042 let needs_conversion = dest_type != src_ext;
2043 let has_page = page.is_some();
2044 if needs_conversion || has_page {
2045 let content_hash = Self::hash_file_content(&source);
2046 let job_key = match content_hash {
2047 Some(h) => JobKey::Hashed(h, page, options.clone()),
2048 None => JobKey::Pathy(source.clone(), page, options.clone()),
2049 };
2050 let job_id = if let Some(&job_id) = convert_job_ids.get(&job_key) {
2051 job_id
2052 } else {
2053 let prior_source_jobs = convert_source_counts.get(&source).copied().unwrap_or(0);
2054 let dest_name = if has_page || prior_source_jobs > 0 {
2055 resource_counter += 1;
2056 format!("x{}", resource_counter)
2057 } else {
2058 Path::new(&source)
2059 .file_stem()
2060 .and_then(|s| s.to_str())
2061 .unwrap_or("image")
2062 .to_string()
2063 };
2064 convert_source_counts.insert(source.clone(), prior_source_jobs + 1);
2065 // Vector-SVG path: opt-in for small PDFs only. We prepare an
2066 // alternate `.svg` destination path alongside the normal raster
2067 // destination so the worker can try the vector-SVG path first, then fall
2068 // back. The file-size heuristic gates this — see
2069 // `should_try_svg_path`.
2070 let try_svg = Self::should_try_svg_path(&source, self.svg_threshold_kb);
2071 let rel_dest = format!("{}.{}", dest_name, dest_type);
2072 let abs_dest = PathBuf::from(&dest_dir).join(&rel_dest);
2073 if let Some(parent) = abs_dest.parent() {
2074 std::fs::create_dir_all(parent).ok();
2075 }
2076 let abs_dest_str = abs_dest.to_string_lossy().to_string();
2077 let svg_paths = if try_svg {
2078 let rel_svg = format!("{}.svg", dest_name);
2079 let abs_svg = PathBuf::from(&dest_dir).join(&rel_svg);
2080 let abs_svg_str = abs_svg.to_string_lossy().to_string();
2081 Some((rel_svg, abs_svg_str))
2082 } else {
2083 None
2084 };
2085 let job_id = convert_jobs.len();
2086 convert_jobs.push(ConvertJob {
2087 job_id,
2088 source: source.clone(),
2089 page,
2090 rel_dest,
2091 abs_dest_str,
2092 svg_paths,
2093 });
2094 convert_job_ids.insert(job_key, job_id);
2095 job_id
2096 };
2097 plans.push(Plan::Convert { idx, options, job_id });
2098 } else {
2099 plans.push(Plan::Copy { idx, source, options });
2100 }
2101 }
2102
2103 // Phase 2: parallel conversions. Bounded worker count to avoid
2104 // oversubscribing when many images are in flight. `convert` itself
2105 // is single-threaded per invocation, so the ceiling is useful CPU
2106 // parallelism — capped at a reasonable limit to avoid fork/memory
2107 // storms with many-image papers.
2108 let convert_count = convert_jobs.len();
2109 // Worker cap controls fork-fan-out of mutool / pdftocairo / convert.
2110 // Each spawn pulls libgs + libpoppler + libpng into a fresh
2111 // address space (~30 ms ambient), so on graphics-heavy papers
2112 // (e.g. LHCb 2402.01336 with 17 unique PDFs) sub-batches at
2113 // cap = 8 added wasted batch boundaries on a 28-CPU host. 22 is
2114 // a measured sweet spot on 28-core machines under the canvas
2115 // sweep workload: high enough to one-shot the typical tail-
2116 // paper graphics fan, low enough to leave headroom for the
2117 // outer cortex_worker pool (12-16 workers) without the kernel
2118 // scheduler thrashing — 12 × 32 ≈ 384 inflight subprocs trips
2119 // the internal 60 s watchdog and produces the "sweep flake"
2120 // pattern in stages 2/4/5. 12 × 22 ≈ 264 stays under the
2121 // measured starvation threshold. The 1910.01256 mini-bench
2122 // (5 PDFs) is unaffected because it is already <= cap.
2123 let worker_cap = std::thread::available_parallelism()
2124 .map(|n| n.get())
2125 .unwrap_or(4)
2126 .clamp(1, 22);
2127 let n_workers = convert_count.min(worker_cap).max(1);
2128 let mut outcomes: Vec<ConvertOutcome> = Vec::with_capacity(convert_count);
2129 if convert_count > 0 {
2130 use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
2131 let next = AtomicUsize::new(0);
2132 // Subprocess tally: telemetry's thread_local! STATE is per-thread,
2133 // and worker threads exit before `phase_us[graphics]` aggregation,
2134 // so worker increments would be lost. Accumulate in a shared
2135 // AtomicU32 here and merge into telemetry once the scope joins.
2136 // One increment per `Self::convert_image_svg` / `Self::convert_image`
2137 // call (the EPS-via-PDF internal pair counts as one).
2138 let subproc_count = AtomicU32::new(0);
2139 let subproc_ref = &subproc_count;
2140 // Copy out of `self` before the scope: the worker closures must not
2141 // borrow `self` (it is `&mut` here), and `CachePolicy` is `Copy`.
2142 let cache_policy = self.cache_policy;
2143 // Unique conversion jobs only. Repeated nodes with the same
2144 // source/page/options share one subprocess result, while distinct
2145 // options keep separate outputs.
2146 let jobs: Vec<&ConvertJob> = convert_jobs.iter().collect();
2147 // Each worker accumulates into a thread-local Vec returned from
2148 // its closure; the main thread merges them after scope join. No
2149 // shared mutable state during the parallel phase — replaces the
2150 // previous `Mutex<Vec<…>>` per project policy (thread_local-only
2151 // for in-memory state, no `Mutex`).
2152 // R35.C: spawn workers with a small (2 MB) stack via
2153 // `spawn_scoped`, which returns Result; if a spawn fails with
2154 // EAGAIN/WouldBlock (canvas + 6 GB ulimit can run out of address
2155 // space on graphics-heavy papers — witness hep-ph0012156, 12778
2156 // formulas, R35.C), drop the failure and let the surviving
2157 // workers pick up the remaining jobs via the shared `next`
2158 // counter. If every spawn fails, run all jobs on the current
2159 // thread instead of crashing.
2160 type WorkerResult = (
2161 Vec<ConvertOutcome>,
2162 latexml_core::util::logger::CapturedDiagnostics,
2163 );
2164 let worker_outcomes: Vec<WorkerResult> = std::thread::scope(|s| {
2165 let handles: Vec<_> = (0..n_workers)
2166 .filter_map(|_| {
2167 std::thread::Builder::new()
2168 .stack_size(2 * 1024 * 1024)
2169 .spawn_scoped(s, || {
2170 // Capture this worker's diagnostics and hand them back on join.
2171 // LOG_BUFFER + REPORT are #[thread_local], so an Error!/Warn! a
2172 // conversion raises on this worker would otherwise be lost from
2173 // cortex.log AND from status_code. The main thread replays them
2174 // (text + count deltas) in worker order after the scope joins.
2175 latexml_core::util::logger::capture(|| {
2176 let mut local = Vec::<ConvertOutcome>::new();
2177 loop {
2178 let i = next.fetch_add(1, Ordering::Relaxed);
2179 if i >= jobs.len() {
2180 break;
2181 }
2182 let ConvertJob {
2183 job_id,
2184 source,
2185 page,
2186 rel_dest,
2187 abs_dest_str,
2188 svg_paths,
2189 } = jobs[i];
2190 // Fresh converter-diagnostic slate for this node, so the
2191 // failed_to_convert Error (if it fires) reports THIS asset's
2192 // converter error, not a previous job's.
2193 take_converter_diag();
2194 // Try vector-SVG path first if requested for this source.
2195 // The cache layer (graphics_cache) hardlinks/copies a
2196 // matching cached output before any subprocess fires and
2197 // round-trips the dimensions through a .dims sidecar so
2198 // hits skip the `read_*_dimensions` re-measure too.
2199 // Misses fall through to the real conversion + measure
2200 // and write back on success. Disable via
2201 // LATEXML_GRAPHICS_CACHE_OFF=1.
2202 let svg_outcome = if let Some((rel_svg, abs_svg)) = svg_paths {
2203 let svg_key = crate::graphics_cache::RenderKey {
2204 page: *page,
2205 density: 0,
2206 ext: "svg",
2207 };
2208 let svg_res = crate::graphics_cache::with_cache_dims(
2209 cache_policy,
2210 source,
2211 abs_svg,
2212 svg_key,
2213 || {
2214 subproc_ref.fetch_add(1, Ordering::Relaxed);
2215 Self::convert_image_svg(source, abs_svg, *page)
2216 },
2217 || {
2218 Self::read_svg_dimensions(abs_svg).map(|(w, h)| {
2219 crate::graphics_cache::CachedDims { width: w, height: h }
2220 })
2221 },
2222 );
2223 match svg_res {
2224 crate::graphics_cache::ConvertResult::Ok { dims } => Some(ConvertOutcome {
2225 job_id: *job_id,
2226 imagesrc: Some(rel_svg.clone()),
2227 raw_dims: dims.map(|d| (d.width, d.height)),
2228 }),
2229 crate::graphics_cache::ConvertResult::Failed => {
2230 Warn!(
2231 "shell",
2232 "svg",
2233 "Graphics: vector-SVG path failed for {}, falling back to raster",
2234 source
2235 );
2236 None
2237 },
2238 }
2239 } else {
2240 None
2241 };
2242 let raster_res = if svg_outcome.is_none() {
2243 let raster_key = crate::graphics_cache::RenderKey {
2244 page: *page,
2245 density: Self::raster_density_for_source(source),
2246 ext: ext_from_path(abs_dest_str),
2247 };
2248 crate::graphics_cache::with_cache_dims(
2249 cache_policy,
2250 source,
2251 abs_dest_str,
2252 raster_key,
2253 || {
2254 subproc_ref.fetch_add(1, Ordering::Relaxed);
2255 Self::convert_image(source, abs_dest_str, dpi, *page)
2256 },
2257 || {
2258 Self::read_image_dimensions(abs_dest_str).map(|(w, h)| {
2259 crate::graphics_cache::CachedDims { width: w, height: h }
2260 })
2261 },
2262 )
2263 } else {
2264 crate::graphics_cache::ConvertResult::Failed
2265 };
2266 let outcome = if let Some(o) = svg_outcome {
2267 o
2268 } else if raster_res.is_ok() {
2269 ConvertOutcome {
2270 job_id: *job_id,
2271 imagesrc: Some(rel_dest.clone()),
2272 raw_dims: raster_res.dims().map(|d| (d.width, d.height)),
2273 }
2274 } else {
2275 // Final-failure: every conversion path exhausted. Mirror
2276 // Perl Graphics.pm L324-329 (Error + `return` with NO
2277 // imagesrc) — do NOT fall back to copying the raw source.
2278 // A raw .eps/.ps/.pdf is never usable on the web; copying it
2279 // would emit a broken `<img src="fig.eps">`. Leaving
2280 // @imagesrc unset makes the HTML5 XSLT
2281 // (LaTeXML-misc-xhtml.xsl L154) render the node as
2282 // `class="ltx_missing ltx_missing_image"` (empty src) — the
2283 // correct "couldn't render" signal. (User directive
2284 // 2026-06-22; raw .eps/.pdf are never web-native.)
2285 // Error class/object mirror Perl Graphics.pm:274 so the
2286 // harness aggregates with engine/package emissions.
2287 // Object is the failure TYPE (not the filename) so the
2288 // harness can aggregate by failure mode. The message marks
2289 // the SOURCE asset (the input that failed) as the subject —
2290 // NOT the target — so the log clearly identifies which
2291 // input could not be rendered; the intended target is
2292 // secondary context.
2293 // Surface the last converter's stderr / spawn error so an
2294 // environment failure (gs not installed, AppArmor denial,
2295 // ImageMagick policy block) is self-diagnosing in the log.
2296 let why = take_converter_diag()
2297 .unwrap_or_else(|| "no converter diagnostic captured".to_string());
2298 Error!(
2299 "imageprocessing",
2300 "failed_to_convert",
2301 "Graphics: failed to convert source asset {} — every converter \
2302 failed, no usable image produced (intended target {}); last \
2303 converter error: {}",
2304 source,
2305 abs_dest_str,
2306 why
2307 );
2308 ConvertOutcome {
2309 job_id: *job_id,
2310 imagesrc: None,
2311 raw_dims: None,
2312 }
2313 };
2314 local.push(outcome);
2315 }
2316 local
2317 })
2318 })
2319 .ok()
2320 })
2321 .collect();
2322 // Note: if EVERY spawn failed (extreme memory pressure), no
2323 // jobs run and graphics will be missing from the output. That
2324 // is much less destructive than panicking the whole worker
2325 // and losing the entire conversion. Surviving workers always
2326 // race for the same `next` counter, so a single survivor is
2327 // enough to complete all jobs.
2328 //
2329 // The same degradation policy applies to a worker that PANICS
2330 // mid-run (observed under fleet memory pressure: 15 papers in
2331 // the 2026-07 full-arXiv run, where join().unwrap() escalated
2332 // a thread panic into a whole-conversion Fatal). Surface the
2333 // payload as a conversion Error and keep the survivors'
2334 // outcomes — a casualty's unfinished job stays unconverted
2335 // (the outcomes_by_job lookups below tolerate missing ids).
2336 handles
2337 .into_iter()
2338 .filter_map(|h| match h.join() {
2339 Ok(res) => Some(res),
2340 Err(payload) => {
2341 let msg = payload
2342 .downcast_ref::<&'static str>()
2343 .map(|s| (*s).to_string())
2344 .or_else(|| payload.downcast_ref::<String>().cloned())
2345 .unwrap_or_else(|| "opaque panic payload".to_string());
2346 Error!(
2347 "imageprocessing",
2348 "worker_panicked",
2349 "Graphics: a conversion worker thread panicked ({}); its \
2350 unfinished jobs are skipped and their graphics left \
2351 unconverted",
2352 msg
2353 );
2354 None
2355 },
2356 })
2357 .collect()
2358 });
2359 // Merge each worker's outcomes AND replay its captured diagnostics on the
2360 // main thread (in worker order), so any conversion Error!/Warn! reaches the
2361 // bound cortex.log and registers in the main REPORT / status_code.
2362 for (v, diags) in worker_outcomes {
2363 outcomes.extend(v);
2364 latexml_core::util::logger::replay_captured(diags);
2365 }
2366 outcomes.sort_by_key(|o| o.job_id);
2367 latexml_core::telemetry::add_graphics_subprocess(subproc_count.load(Ordering::Relaxed));
2368 }
2369 let outcomes_by_job: HashMap<usize, ConvertOutcome> =
2370 outcomes.into_iter().map(|o| (o.job_id, o)).collect();
2371
2372 // Phase 3: serial DOM mutations. Preserves original node order.
2373 let apply_transforms =
2374 |options: &str, raw_dims: Option<(u32, u32)>| -> (Option<u32>, Option<u32>) {
2375 match raw_dims {
2376 Some((w, h)) if !options.is_empty() => {
2377 let (tw, th) = Self::apply_graphicx_transforms(w, h, options, effective_dpi);
2378 (Some(tw), Some(th))
2379 },
2380 Some((w, h)) => (Some(w), Some(h)),
2381 None => (None, None),
2382 }
2383 };
2384 for plan in &plans {
2385 match plan {
2386 Plan::NotFound { idx: _, graphic } => {
2387 // Perl `Post/Graphics.pm:216-219`: Warn + `return` WITHOUT setting
2388 // @imagesrc. An earlier Rust-only promotion to Error (2026-05-08)
2389 // was reverted once the missing `doc.get_search_paths()` half of
2390 // `find_graphics_paths` was fixed; with sources findable this branch
2391 // hits only when the .tex references a genuinely non-existent file.
2392 // We must NOT then set `imagesrc` to the raw `graphic` path: that
2393 // emits a broken `<img src="missing.eps">` (and leaks a raw
2394 // .eps/.pdf to the web). Leaving @imagesrc unset makes the HTML5
2395 // XSLT (LaTeXML-misc-xhtml.xsl L154) render the node as
2396 // `class="ltx_missing ltx_missing_image"` (empty src) — the correct
2397 // "couldn't render" signal, matching Perl (user directive
2398 // 2026-06-22; raw .eps/.pdf are never web-native).
2399 Warn!(
2400 "expected",
2401 "source",
2402 "No graphic source found; skipping (source was '{}')",
2403 graphic
2404 );
2405 },
2406 Plan::Copy { idx, source, options } => {
2407 let mut node_mut = nodes[*idx].clone();
2408 // Content-hash dedup: if a byte-identical source with the
2409 // same options was already copied (and rotated), point this
2410 // node at the same rel. Avoids both duplicate I/O and a
2411 // duplicate output file in the bundle. Fall back to source-
2412 // path keying when the file can't be hashed.
2413 let hash_opt = Self::hash_file_content(source);
2414 let key = match hash_opt {
2415 Some(h) => CopyKey::Hashed(h, options.clone()),
2416 None => CopyKey::Pathy(source.clone(), options.clone()),
2417 };
2418 let rel = if let Some(existing) = copy_dedup.get(&key) {
2419 existing.clone()
2420 } else {
2421 let rel_opt = Self::copy_to_destination(source, &source_dir, &dest_dir);
2422 let rel = rel_opt.unwrap_or_else(|| {
2423 Path::new(source)
2424 .strip_prefix(&source_dir)
2425 .unwrap_or_else(|_| Path::new(source))
2426 .to_string_lossy()
2427 .to_string()
2428 });
2429 // Plan::Copy fires for web-native sources (PNG / JPG / GIF
2430 // / SVG) where `dest_type == src_ext`. graphicx `angle=`
2431 // rotation IS meaningful here — the source carries no PDF
2432 // /Rotate metadata to pre-rotate from. Apply via convert.
2433 // Perl semantics (Util/Image.pm:image_graphicx_complex
2434 // L390-394): IM `Rotate` with `degrees => -$a1` — graphicx
2435 // angle is CCW; convert -rotate is CW; negate to match.
2436 //
2437 // ... but ONLY for a raster source. Perl `Post/Graphics.pm`
2438 // L264-271 refuses every non-scaling transform on a type
2439 // whose `raster` property is false, warns `limitation`, and
2440 // trivializes the transform so plain scaling still applies.
2441 // Rotating an SVG here would hand `convert` a vector source
2442 // and a `.svg` destination: IM rasterizes through its SVG
2443 // delegate and writes that raster back out under the .svg
2444 // name, so the bundle ends up with a file that is no longer
2445 // the drawing it claims to be. The scaling half is unaffected
2446 // — `apply_transforms` below still runs.
2447 let angle = Self::parse_angle_option(options).unwrap_or(0.0);
2448 let is_raster = self
2449 .type_properties
2450 .get(ext_from_path(source))
2451 .and_then(|p| p.raster)
2452 .unwrap_or(true);
2453 if angle.abs() > 0.5 && !is_raster {
2454 Warn!(
2455 "limitation",
2456 "graphics",
2457 "Cannot (yet) apply complex transforms to non-raster images: dropping angle={} \
2458 for {}",
2459 angle,
2460 source
2461 );
2462 } else if angle.abs() > 0.5 {
2463 let dest_full = PathBuf::from(&dest_dir).join(&rel);
2464 Self::rotate_image_inplace(&dest_full.to_string_lossy(), -angle);
2465 }
2466 copy_dedup.insert(key, rel.clone());
2467 rel
2468 };
2469 let raw_dims = Self::read_source_dimensions(source);
2470 if raw_dims.is_none() {
2471 // Perl Graphics.pm L310-312 (triv_scaling, image module present
2472 // but size unusable): warn rather than silently omitting.
2473 Warn!(
2474 "expected",
2475 "image",
2476 "Couldn't get usable image size for {}",
2477 source
2478 );
2479 }
2480 let (w, h) = apply_transforms(options, raw_dims);
2481 Self::set_graphic_src(&mut node_mut, &rel, w, h);
2482 },
2483 Plan::Convert { idx, options, job_id } => {
2484 if let Some(out) = outcomes_by_job.get(job_id) {
2485 let mut node_mut = nodes[*idx].clone();
2486 if let Some(imagesrc) = &out.imagesrc {
2487 // Plan::Convert handles non-raster sources (EPS, PS, PDF,
2488 // AI). With ps2pdf's /Rotate-injection path disabled (see
2489 // should_try_eps_pdf_path), all of these now go through
2490 // ImageMagick `convert` (or pdftocairo for plain .pdf),
2491 // neither of which pre-applies graphicx rotation. So
2492 // apply the graphicx angle uniformly here.
2493 //
2494 // Perl semantics (Util/Image.pm:image_graphicx_complex
2495 // L390-394): `image_internalop('Rotate', degrees => -$a1)`.
2496 // ImageMagick Rotate is CCW (matches graphicx); from CLI
2497 // it's CW → pass -angle to match Perl's intent.
2498 let angle = Self::parse_angle_option(options).unwrap_or(0.0);
2499 if angle.abs() > 0.5 {
2500 let dest_full = PathBuf::from(&dest_dir).join(imagesrc);
2501 Self::rotate_image_inplace(&dest_full.to_string_lossy(), -angle);
2502 }
2503 let (w, h) = apply_transforms(options, out.raw_dims);
2504 Self::set_graphic_src(&mut node_mut, imagesrc, w, h);
2505 }
2506 }
2507 },
2508 }
2509 }
2510
2511 Info!(
2512 "graphics",
2513 "process",
2514 "Graphics {} {} to process",
2515 doc.get_destination().unwrap_or("?"),
2516 n_to_process
2517 );
2518 Ok(vec![doc])
2519 }
2520}
2521
2522#[cfg(test)]
2523mod tests {
2524 use super::*;
2525 // `EnvGuard` (env mutation, serialised + restored) and `TempDir` (unique
2526 // name, removed on drop including on panic) are shared with
2527 // `graphics_cache::tests`; see that module for the rationale. `EnvGuard` is
2528 // only referenced by the `#[cfg(unix)]` density test below, so gate its
2529 // import the same way or it reads as unused on Windows (`-D warnings`).
2530 #[cfg(unix)]
2531 use crate::test_env::EnvGuard;
2532 use crate::test_env::TempDir;
2533
2534 /// `run_with_timeout` kills the child and returns `None` when the
2535 /// process exceeds the deadline. Uses `sleep` as a stand-in for any
2536 /// runaway subprocess (convert, gs, mutool, …).
2537 #[test]
2538 fn run_with_timeout_kills_slow_child() {
2539 let start = std::time::Instant::now();
2540 let mut cmd = std::process::Command::new("sleep");
2541 cmd.arg("10");
2542 let result = Graphics::run_with_timeout(cmd, std::time::Duration::from_millis(200));
2543 let elapsed = start.elapsed();
2544 assert!(
2545 result.is_none(),
2546 "run_with_timeout should return None on kill"
2547 );
2548 // We expect around 200 ms (+ ≤ 50 ms poll interval + SIGKILL reap
2549 // overhead). Give it 2 s of slack for CI noise.
2550 assert!(
2551 elapsed < std::time::Duration::from_secs(2),
2552 "killed run should return quickly, took {:?}",
2553 elapsed
2554 );
2555 }
2556
2557 /// Fast-completing child returns its real exit status, not a kill.
2558 #[test]
2559 fn run_with_timeout_returns_status_for_fast_child() {
2560 let cmd = std::process::Command::new("true");
2561 let result = Graphics::run_with_timeout(cmd, std::time::Duration::from_secs(5));
2562 let status = result.expect("expected clean exit");
2563 assert!(status.success(), "`true` should exit successfully");
2564 }
2565
2566 /// Missing binary → `None`, not a panic.
2567 #[test]
2568 fn run_with_timeout_handles_spawn_failure() {
2569 let cmd = std::process::Command::new("/this/binary/does/not/exist/12345");
2570 let result = Graphics::run_with_timeout(cmd, std::time::Duration::from_secs(1));
2571 assert!(result.is_none());
2572 }
2573
2574 /// Explicit-threshold heuristic: PDF under threshold triggers SVG
2575 /// attempt, large PDF does not, non-PDF is always skipped.
2576 #[test]
2577 fn should_try_svg_path_explicit_threshold() {
2578 let tmp = TempDir::new("svg_gate");
2579 let small_pdf = tmp.join("small.pdf");
2580 let big_pdf = tmp.join("big.pdf");
2581 let png = tmp.join("raster.png");
2582 std::fs::write(&small_pdf, vec![0u8; 10 * 1024]).unwrap(); // 10 KB
2583 std::fs::write(&big_pdf, vec![0u8; 500 * 1024]).unwrap(); // 500 KB
2584 std::fs::write(&png, vec![0u8; 10 * 1024]).unwrap(); // PNG, irrelevant size
2585
2586 // Under explicit threshold → true.
2587 assert!(Graphics::should_try_svg_path(
2588 small_pdf.to_str().unwrap(),
2589 200
2590 ));
2591 // At/over explicit threshold → false.
2592 assert!(!Graphics::should_try_svg_path(
2593 big_pdf.to_str().unwrap(),
2594 200
2595 ));
2596 // Non-PDF → always false even under threshold.
2597 assert!(!Graphics::should_try_svg_path(png.to_str().unwrap(), 200));
2598 // Missing file → false, not panic.
2599 assert!(!Graphics::should_try_svg_path("/no/such/file.pdf", 200));
2600 }
2601
2602 /// Auto-detect path (`threshold_kb == 0`): vector-only PDFs trigger
2603 /// the SVG attempt, PDFs containing image XObjects do NOT. Uses the
2604 /// real fixtures (`cifar10_vector.pdf`, `pathological_vector.pdf`,
2605 /// `raster_with_image.pdf`) under `latexml_post/tests/fixtures/`.
2606 #[test]
2607 fn should_try_svg_path_auto_detect() {
2608 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
2609 let cifar = fixtures.join("cifar10_vector.pdf");
2610 let pathological = fixtures.join("pathological_vector.pdf");
2611 let raster = fixtures.join("raster_with_image.pdf");
2612 assert!(cifar.exists(), "fixture missing: {}", cifar.display());
2613 assert!(
2614 pathological.exists(),
2615 "fixture missing: {}",
2616 pathological.display()
2617 );
2618 assert!(raster.exists(), "fixture missing: {}", raster.display());
2619
2620 // Vector PDFs (no /Subtype /Image marker) → SVG path activated.
2621 assert!(
2622 Graphics::should_try_svg_path(cifar.to_str().unwrap(), 0),
2623 "vector-only PDF must trigger SVG path under auto-detect"
2624 );
2625 assert!(
2626 Graphics::should_try_svg_path(pathological.to_str().unwrap(), 0),
2627 "pgfplots-style vector PDF must trigger SVG path under auto-detect"
2628 );
2629
2630 // Raster PDF (has /Subtype /Image) → SVG path SKIPPED.
2631 assert!(
2632 !Graphics::should_try_svg_path(raster.to_str().unwrap(), 0),
2633 "raster-containing PDF must skip auto-detect SVG path"
2634 );
2635
2636 // Direct detector sanity check.
2637 assert_eq!(
2638 Graphics::pdf_has_image_xobject(cifar.to_str().unwrap()),
2639 Some(false)
2640 );
2641 assert_eq!(
2642 Graphics::pdf_has_image_xobject(raster.to_str().unwrap()),
2643 Some(true)
2644 );
2645 }
2646
2647 #[test]
2648 fn postscript_density_caps_huge_bounding_box() {
2649 let tmp = TempDir::new("ps_density");
2650 let normal = tmp.join("normal.eps");
2651 let huge = tmp.join("huge.eps");
2652 std::fs::write(
2653 &normal,
2654 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 567 567\n",
2655 )
2656 .unwrap();
2657 std::fs::write(
2658 &huge,
2659 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 14 14 11353 11353\n",
2660 )
2661 .unwrap();
2662
2663 assert_eq!(
2664 Graphics::raster_density_for_source(normal.to_str().unwrap()),
2665 Graphics::DEFAULT_RASTER_DENSITY
2666 );
2667 assert_eq!(
2668 Graphics::raster_density_for_source(huge.to_str().unwrap()),
2669 13
2670 );
2671 assert_eq!(
2672 read_postscript_bounding_box(huge.to_str().unwrap()),
2673 Some((11339.0, 11339.0))
2674 );
2675 }
2676
2677 #[test]
2678 fn pdf_density_caps_huge_page_box() {
2679 let dir = TempDir::new("pdf_density");
2680 let tmp = dir.join("page.pdf");
2681 std::fs::write(
2682 &tmp,
2683 b"%PDF-1.4
26841 0 obj
2685<< /Type /Page /MediaBox [0 0 4218 2437] >>
2686endobj
2687",
2688 )
2689 .unwrap();
2690
2691 assert_eq!(
2692 latexml_core::util::image::read_pdf_page_box(Path::new(tmp.to_str().unwrap())),
2693 Some((4218.0, 2437.0))
2694 );
2695 assert_eq!(
2696 Graphics::raster_density_for_source(tmp.to_str().unwrap()),
2697 34
2698 );
2699 }
2700
2701 /// SVG viewBox parsing extracts width/height — and, when both are present,
2702 /// the viewBox wins over the root lengths. That precedence is load-bearing:
2703 /// `pdftocairo -svg` writes `width="612pt" … viewBox="0 0 612 792"`, so
2704 /// preferring the lengths would rescale every PDF-derived figure by 96/72.
2705 #[test]
2706 fn read_svg_dimensions_parses_viewbox() {
2707 let dir = TempDir::new("svg_dim");
2708 let tmp = dir.join("dims.svg");
2709 std::fs::write(
2710 &tmp,
2711 r#"<?xml version="1.0"?>
2712<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" width="10cm" height="7.5cm">
2713 <rect width="640" height="480" fill="black"/>
2714</svg>"#,
2715 )
2716 .unwrap();
2717 let dims = Graphics::read_svg_dimensions(tmp.to_str().unwrap()).expect("dims");
2718 assert_eq!(dims, (640, 480));
2719 }
2720
2721 /// Falls back to width/height attrs when viewBox is missing — **converting**
2722 /// the unit, not truncating it. `123.7pt` is 123.7/72 in = 164.9 px; the
2723 /// previous reader dropped the `pt` and called it 124 px, so a `\includegraphics`
2724 /// of this file rendered at three quarters of its size (and, for `cm`/`in`
2725 /// sources, at a small fraction of it — issue 498 follow-up).
2726 #[test]
2727 fn read_svg_dimensions_falls_back_to_width_height() {
2728 let dir = TempDir::new("svg_dim_fallback");
2729 let tmp = dir.join("dims.svg");
2730 std::fs::write(
2731 &tmp,
2732 r#"<svg xmlns="http://www.w3.org/2000/svg" width="123.7pt" height="99.4pt">
2733 <rect/>
2734</svg>"#,
2735 )
2736 .unwrap();
2737 let dims = Graphics::read_svg_dimensions(tmp.to_str().unwrap()).expect("dims");
2738 assert_eq!(dims, (165, 133));
2739 }
2740
2741 /// The no-usable-geometry case must stay `None` all the way through, so the
2742 /// caller writes no `imagewidth`/`imageheight` at all. Emitting a bogus
2743 /// number here is worse than emitting nothing: with no attributes the browser
2744 /// uses the SVG's own intrinsic size, which is right.
2745 #[test]
2746 fn read_svg_dimensions_declines_a_percentage_sized_root() {
2747 let dir = TempDir::new("svg_dim_pct");
2748 let tmp = dir.join("dims.svg");
2749 std::fs::write(
2750 &tmp,
2751 r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><rect/></svg>"#,
2752 )
2753 .unwrap();
2754 assert_eq!(Graphics::read_svg_dimensions(tmp.to_str().unwrap()), None);
2755 }
2756
2757 /// Three `<graphics>` nodes over one source: two share `options`, one
2758 /// differs. `process` must coalesce the matching pair into a single
2759 /// conversion and run a second one for the differing options — proven by
2760 /// counting the lines a fake `convert` on `PATH` appends to its log.
2761 ///
2762 /// **The cache must be bypassed for this to mean anything.** The
2763 /// observable here is "how many converter subprocesses ran", and
2764 /// `graphics_cache` is content-addressed against a *host-persistent* root
2765 /// (`$XDG_CACHE_HOME/latexml-oxide/graphics`). With the cache live this
2766 /// test was self-poisoning: the first run stored the shim's output under a
2767 /// key derived from these fixed source bytes, and every later run was
2768 /// served from that entry, spawned nothing, and died on a missing log
2769 /// (issue 401). The two jobs also share one cache key — same bytes, page,
2770 /// density and extension, differing only in destination name — so even a
2771 /// pristine cache could serve job two from job one and see a single log
2772 /// line. `CachePolicy::Bypass` removes both, without touching any env var.
2773 #[test]
2774 #[cfg(unix)]
2775 fn process_coalesces_only_matching_conversion_options() {
2776 use std::os::unix::fs::PermissionsExt;
2777
2778 use crate::{
2779 document::{PostDocument, PostDocumentOptions},
2780 graphics_cache::CachePolicy,
2781 };
2782
2783 let tmp = TempDir::new("graphics_dedupe");
2784 let source = tmp.join("plot.ai");
2785 std::fs::write(&source, "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n").unwrap();
2786 let log = tmp.join("convert.log");
2787 let fake_convert = tmp.join("convert");
2788 // Log the args AND write a non-empty file at the dest (the last positional
2789 // arg) — `convert_image` now requires actual output, not just exit 0.
2790 // The log path is derived from `$0` (the kernel hands a shebang script its
2791 // resolved path, so PATH lookup still yields an absolute `dirname`) rather
2792 // than from an env var: one less process-global to synchronise.
2793 std::fs::write(
2794 &fake_convert,
2795 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$(dirname \"$0\")/convert.log\"\n\
2796 for a in \"$@\"; do d=\"$a\"; done\nprintf x > \"$d\"\nexit 0\n",
2797 )
2798 .unwrap();
2799 let mut perms = std::fs::metadata(&fake_convert).unwrap().permissions();
2800 perms.set_mode(0o755);
2801 std::fs::set_permissions(&fake_convert, perms).unwrap();
2802
2803 let old_path = std::env::var("PATH").unwrap_or_default();
2804 let mut env = EnvGuard::acquire();
2805 env.set("PATH", &format!("{}:{}", tmp.path().display(), old_path));
2806 let xml = format!(
2807 r#"<?xml version="1.0"?>
2808<document xmlns="http://dlmf.nist.gov/LaTeXML" xml:id="d">
2809 <graphics graphic="plot.ai" candidates="{0}" options="width=20pt"/>
2810 <graphics graphic="plot.ai" candidates="{0}" options="width=40pt"/>
2811 <graphics graphic="plot.ai" candidates="{0}" options="width=20pt"/>
2812</document>"#,
2813 source.display()
2814 );
2815 let doc_opts = PostDocumentOptions {
2816 destination: Some(tmp.join("out.html").display().to_string()),
2817 source_directory: Some(tmp.path().display().to_string()),
2818 ..Default::default()
2819 };
2820 let doc = PostDocument::new_from_string(&xml, doc_opts).unwrap();
2821 let mut graphics = Graphics::new(None, true).with_cache_policy(CachePolicy::Bypass);
2822 let nodes = graphics.to_process(&doc);
2823 assert_eq!(nodes.len(), 3);
2824
2825 let docs = graphics.process(doc, nodes).unwrap();
2826 let out = docs[0].to_xml_string();
2827 let log_contents = std::fs::read_to_string(&log).unwrap_or_else(|e| {
2828 panic!(
2829 "fake `convert` never ran: no log at {} ({e}). The shim is on PATH and the cache is \
2830 bypassed, so `process` should have spawned it.",
2831 log.display()
2832 )
2833 });
2834 assert_eq!(
2835 log_contents.lines().count(),
2836 2,
2837 "matching source/page/options should coalesce, but different options need separate \
2838 conversions; convert log was:\n{log_contents}"
2839 );
2840 assert_eq!(out.matches(r#"imagesrc="plot.png""#).count(), 2);
2841 assert_eq!(out.matches(r#"imagesrc="x1.png""#).count(), 1);
2842 }
2843
2844 /// `angle=` on a **non-raster** source must not reach `convert`.
2845 ///
2846 /// Perl `Post/Graphics.pm` L264-271 refuses non-scaling transforms on a type
2847 /// whose `raster` property is false, warns `limitation`, and trivializes the
2848 /// transform. Rust's `svg` entry carries `raster: Some(false)` but nothing
2849 /// read it, so `Plan::Copy` handed IM a vector source and a `.svg`
2850 /// destination — IM rasterizes via its SVG delegate and writes the raster
2851 /// back under the `.svg` name, leaving the bundle with a file that is no
2852 /// longer the drawing it claims to be.
2853 ///
2854 /// A fake `convert` on PATH makes this observable without ImageMagick: it
2855 /// logs its arguments and overwrites its destination, so "never invoked" is
2856 /// the absence of a log AND an unchanged destination file. The raster half of
2857 /// the contract still has to hold, so the same document rotates a PNG.
2858 #[test]
2859 #[cfg(unix)]
2860 fn rotation_is_dropped_for_non_raster_sources_but_kept_for_raster() {
2861 use std::os::unix::fs::PermissionsExt;
2862
2863 use crate::{
2864 document::{PostDocument, PostDocumentOptions},
2865 graphics_cache::CachePolicy,
2866 };
2867
2868 let tmp = TempDir::new("graphics_nonraster_rotate");
2869 let svg_body = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><rect/></svg>"#;
2870 let svg = tmp.join("draw.svg");
2871 std::fs::write(&svg, svg_body).unwrap();
2872 // A 1×1 PNG: enough for `imagesize`, and a raster type, so it MUST rotate.
2873 let png = tmp.join("dot.png");
2874 std::fs::write(&png, ONE_PIXEL_PNG).unwrap();
2875
2876 let log = tmp.join("convert.log");
2877 let fake_convert = tmp.join("convert");
2878 std::fs::write(
2879 &fake_convert,
2880 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$(dirname \"$0\")/convert.log\"\n\
2881 for a in \"$@\"; do d=\"$a\"; done\nprintf x > \"$d\"\nexit 0\n",
2882 )
2883 .unwrap();
2884 let mut perms = std::fs::metadata(&fake_convert).unwrap().permissions();
2885 perms.set_mode(0o755);
2886 std::fs::set_permissions(&fake_convert, perms).unwrap();
2887
2888 let old_path = std::env::var("PATH").unwrap_or_default();
2889 let mut env = EnvGuard::acquire();
2890 env.set("PATH", &format!("{}:{}", tmp.path().display(), old_path));
2891
2892 let dest = tmp.join("sub").join("out.html");
2893 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2894 let xml = format!(
2895 r#"<?xml version="1.0"?>
2896<document xmlns="http://dlmf.nist.gov/LaTeXML" xml:id="d">
2897 <graphics graphic="draw" candidates="{}" options="angle=90" xml:id="g1"/>
2898 <graphics graphic="dot" candidates="{}" options="angle=90" xml:id="g2"/>
2899</document>"#,
2900 svg.display(),
2901 png.display()
2902 );
2903 let doc_opts = PostDocumentOptions {
2904 destination: Some(dest.display().to_string()),
2905 source_directory: Some(tmp.path().display().to_string()),
2906 ..Default::default()
2907 };
2908 let doc = PostDocument::new_from_string(&xml, doc_opts).unwrap();
2909 let mut graphics = Graphics::new(None, true).with_cache_policy(CachePolicy::Bypass);
2910 let nodes = graphics.to_process(&doc);
2911 assert_eq!(nodes.len(), 2);
2912 graphics.process(doc, nodes).unwrap();
2913
2914 let out_svg = dest.parent().unwrap().join("draw.svg");
2915 assert_eq!(
2916 std::fs::read_to_string(&out_svg).ok().as_deref(),
2917 Some(svg_body),
2918 "the copied SVG must be the original drawing, not a `convert` rewrite"
2919 );
2920 // One line per invocation; the rotate line names its source AND its
2921 // `.rotated` scratch file, so match lines rather than occurrences.
2922 let invocations = std::fs::read_to_string(&log).unwrap_or_default();
2923 let calls_for = |name: &str| invocations.lines().filter(|l| l.contains(name)).count();
2924 assert_eq!(
2925 calls_for("draw.svg"),
2926 0,
2927 "`convert` must not be invoked for a non-raster source; log was:\n{invocations}"
2928 );
2929 assert_eq!(
2930 calls_for("dot.png"),
2931 1,
2932 "a raster source with angle= must still be rotated; log was:\n{invocations}"
2933 );
2934 }
2935
2936 /// Characterization matrix for the post-side graphicx algebra.
2937 ///
2938 /// **Pins behaviour, not correctness.** `apply_graphicx_transforms` works in
2939 /// device pixels at the effective DPI (100 unless a `<?latexml DPI=?>` PI
2940 /// says otherwise) and is a *separate* implementation from the engine's
2941 /// `image_graphicx_sizer`; the two disagree, and the disagreements are
2942 /// recorded here so the planned unification has to resolve them deliberately.
2943 ///
2944 /// Source is 200x100 px throughout, DPI 100. Notes on individual rows:
2945 ///
2946 /// * `width=100pt` -> 138 px: 100pt = 99.6265bp, x 100/72.27 = 137.85, ceil.
2947 /// Until 2026-08-04 this read 139, because the local parser skipped Perl's
2948 /// pt->bp step and multiplied the raw 100 — so the HTML pixel count and the
2949 /// engine's reserved box disagreed by a pixel on every explicit size.
2950 /// * `width=1in` / `width=2cm` are honoured since the same date; the local
2951 /// parser only knew `pt` and `px` and silently dropped anything else. This
2952 /// is unreachable from ordinary LaTeX either way — `graphicx_sty`
2953 /// normalizes to pt first (`width=2cm` arrives as `width=56.9055pt`,
2954 /// verified) — so it is defensive behaviour, pinned to stay honest about
2955 /// what the parser accepts.
2956 /// * `angle=90` swaps the box, and now does so via the true rotated bounding
2957 /// box rather than a 90/270 special case, which is also what
2958 /// `convert -rotate` writes for an oblique angle.
2959 /// * The `width=137.9979pt` row is issue 498's own witness: 191 px, unmoved
2960 /// by any of the above.
2961 #[test]
2962 fn apply_graphicx_transforms_matrix() {
2963 #[rustfmt::skip]
2964 let matrix: &[(&str, u32, u32)] = &[
2965 ("", 200, 100),
2966 ("width=100pt", 138, 69),
2967 ("width=100pt,keepaspectratio=true", 138, 69),
2968 ("scale=0.5", 100, 50),
2969 ("height=25pt,keepaspectratio=true", 69, 35),
2970 ("width=100pt,height=80pt", 138, 111),
2971 ("width=1in,keepaspectratio=true", 100, 50), // 1in at DPI 100
2972 ("width=2cm", 79, 40), // 2cm = 0.787in
2973 ("angle=90", 100, 200),
2974 ("width=137.9979pt,keepaspectratio=true", 191, 96), // issue 498 witness
2975 ];
2976 // Report every divergence at once: when this matrix moves it is normally
2977 // because a shared rule changed, and the whole delta is the signal.
2978 let mut deltas = Vec::new();
2979 for (opts, want_w, want_h) in matrix {
2980 let got = Graphics::apply_graphicx_transforms(200, 100, opts, 100);
2981 if got != (*want_w, *want_h) {
2982 deltas.push(format!(
2983 " [{opts}] pinned ({want_w}, {want_h}) got {got:?}"
2984 ));
2985 }
2986 }
2987 assert!(
2988 deltas.is_empty(),
2989 "{} of {} pinned rows moved:\n{}",
2990 deltas.len(),
2991 matrix.len(),
2992 deltas.join("\n")
2993 );
2994 }
2995
2996 /// `parse_angle_option`'s doc claims it normalizes to {0,90,180,270} when
2997 /// within 5 degrees. It does not — every value comes back raw. Pinned as it
2998 /// behaves; the doc comment is the thing that is wrong.
2999 #[test]
3000 fn parse_angle_option_returns_the_raw_angle() {
3001 for (opts, want) in [
3002 ("angle=90", Some(90.0)),
3003 ("angle=-90", Some(-90.0)),
3004 ("angle=88", Some(88.0)), // NOT snapped to 90
3005 ("angle=45", Some(45.0)),
3006 ("angle=180", Some(180.0)),
3007 ("angle=0.2", Some(0.2)), // below the 0.5 threshold callers apply
3008 ("angle=272", Some(272.0)),
3009 ("", None),
3010 ("width=100pt", None),
3011 ] {
3012 assert_eq!(Graphics::parse_angle_option(opts), want, "options {opts:?}");
3013 }
3014 }
3015
3016 /// Which reader a source reaches, and what it can measure. EPS answers
3017 /// `None` here: the post raster reader is the `imagesize` crate, which has no
3018 /// PostScript support, so an EPS is never sized on the trivial-copy path — it
3019 /// always goes through `Plan::Convert` and is measured from the produced PNG.
3020 #[test]
3021 fn read_source_dimensions_dispatch_matrix() {
3022 let tmp = TempDir::new("post_dispatch");
3023 let svg = tmp.join("a.svg");
3024 std::fs::write(&svg, r#"<svg viewBox="0 0 200 100"><rect/></svg>"#).unwrap();
3025 let svg_upper = tmp.join("B.SVG");
3026 std::fs::write(&svg_upper, r#"<svg viewBox="0 0 60 40"><rect/></svg>"#).unwrap();
3027 let png = tmp.join("c.png");
3028 std::fs::write(&png, ONE_PIXEL_PNG).unwrap();
3029 let eps = tmp.join("d.eps");
3030 std::fs::write(
3031 &eps,
3032 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n",
3033 )
3034 .unwrap();
3035
3036 let dims = |p: &Path| Graphics::read_source_dimensions(p.to_str().unwrap());
3037 assert_eq!(dims(&svg), Some((200, 100)), "svg via viewBox");
3038 assert_eq!(
3039 dims(&svg_upper),
3040 Some((60, 40)),
3041 "extension match is case-insensitive"
3042 );
3043 assert_eq!(dims(&png), Some((1, 1)), "png via imagesize");
3044 assert_eq!(dims(&eps), None, "EPS is unmeasurable here, by design");
3045 }
3046
3047 /// The three `%%BoundingBox` shapes the PS reader accepts. Values are bp; the
3048 /// two-value form returns the extent, the full form the raw corners.
3049 #[test]
3050 fn postscript_bounding_box_forms() {
3051 let tmp = TempDir::new("post_psbbox");
3052 let offset = tmp.join("o.eps");
3053 std::fs::write(
3054 &offset,
3055 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 210 120\n",
3056 )
3057 .unwrap();
3058 assert_eq!(
3059 read_postscript_bounding_box(offset.to_str().unwrap()),
3060 Some((200.0, 100.0)),
3061 "extent, not corners"
3062 );
3063 assert_eq!(
3064 read_postscript_bounding_box_full(offset.to_str().unwrap()),
3065 Some((10.0, 20.0, 200.0, 100.0)),
3066 "full form is (llx, lly, width, height) — NOT (llx,lly,urx,ury)"
3067 );
3068 // The deferred `(atend)` form: the real numbers appear later in the file.
3069 let atend = tmp.join("a.eps");
3070 std::fs::write(
3071 &atend,
3072 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\nstuff\n%%BoundingBox: 0 0 300 150\n",
3073 )
3074 .unwrap();
3075 assert_eq!(
3076 read_postscript_bounding_box(atend.to_str().unwrap()),
3077 Some((300.0, 150.0))
3078 );
3079 }
3080
3081 /// Rasterization density is a *quality* knob, independent of the sizing DPI:
3082 /// `DEFAULT_RASTER_DENSITY` (120) unless the source's own box is large enough
3083 /// that rendering at 120 would exceed `MAX_RASTER_DIMENSION_PX`.
3084 #[test]
3085 fn raster_density_is_capped_by_source_box_size() {
3086 let tmp = TempDir::new("post_density");
3087 let small = tmp.join("s.eps");
3088 std::fs::write(
3089 &small,
3090 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n",
3091 )
3092 .unwrap();
3093 assert_eq!(
3094 Graphics::raster_density_for_source(small.to_str().unwrap()),
3095 120
3096 );
3097
3098 let pdf = tmp.join("s.pdf");
3099 std::fs::write(&pdf, "%PDF-1.4\n<< /MediaBox [0 0 200 100] >>\n").unwrap();
3100 assert_eq!(
3101 Graphics::raster_density_for_source(pdf.to_str().unwrap()),
3102 120
3103 );
3104
3105 // No measurable box (a raster source) — the default stands.
3106 let png = tmp.join("s.png");
3107 std::fs::write(&png, ONE_PIXEL_PNG).unwrap();
3108 assert_eq!(
3109 Graphics::raster_density_for_source(png.to_str().unwrap()),
3110 120
3111 );
3112
3113 // A 2000bp-wide source at 120 dpi would be 3333 px, over the 2048 cap, so
3114 // the density drops to floor(2048 * 72 / 2000) = 73.
3115 let big = tmp.join("b.eps");
3116 std::fs::write(
3117 &big,
3118 "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 2000 1000\n",
3119 )
3120 .unwrap();
3121 assert_eq!(
3122 Graphics::raster_density_for_source(big.to_str().unwrap()),
3123 73
3124 );
3125 }
3126
3127 /// Smallest valid PNG: 1×1, 8-bit RGB. `imagesize` reads its IHDR, so the
3128 /// sizing half of `Plan::Copy` succeeds and no `expected:image` warn fires.
3129 const ONE_PIXEL_PNG: &[u8] = &[
3130 0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, // signature
3131 0x00, 0x00, 0x00, 0x0d, b'I', b'H', b'D', b'R', // IHDR length + type
3132 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1 × 1
3133 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, // bit depth/colour + CRC
3134 0x00, 0x00, 0x00, 0x0c, b'I', b'D', b'A', b'T', // IDAT length + type
3135 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
3136 0xb0, // deflate stream + CRC
3137 0x00, 0x00, 0x00, 0x00, b'I', b'E', b'N', b'D', 0xae, 0x42, 0x60, 0x82,
3138 ];
3139
3140 /// A post-processing diagnostic raised on a WORKER THREAD must reach the MAIN
3141 /// thread's bound log AND register in the main `REPORT` status counters — both
3142 /// `LOG_BUFFER` and `REPORT` are `#[thread_local]`, so a worker's `Error!`
3143 /// would otherwise be lost from cortex.log and from `status_code`. This
3144 /// exercises the exact "thread join + fold" the graphics pool uses: each
3145 /// worker returns its [`CapturedDiagnostics`] from `logger::capture`, and the
3146 /// main thread folds them in via `replay_captured` after join — no shared
3147 /// writeable state, no lock, no env mutation. (Companion: the real pipeline is
3148 /// validated end-to-end by converting a document with an unconvertible image;
3149 /// see docs and the manual `--preload`/`--dest` smoke.)
3150 #[test]
3151 fn worker_diagnostics_fold_into_main_thread_on_join() {
3152 // The `log` macros are inert until a logger + level are installed (the
3153 // CLI/cortex do this at startup). `init` is process-global + idempotent
3154 // across tests; force the level since `init` skips it if already installed.
3155 let _ = latexml_core::util::logger::init(log::LevelFilter::Info);
3156 log::set_max_level(log::LevelFilter::Info);
3157
3158 latexml_core::util::logger::bind_log();
3159 let before = latexml_core::common::error::snapshot_report_counts();
3160
3161 // Two workers, each emitting a post Error! under capture (so the diagnostic
3162 // lands in the worker's own thread-local buffer + REPORT), returning their
3163 // CapturedDiagnostics on join — exactly the graphics pool's pattern.
3164 let captured: Vec<latexml_core::util::logger::CapturedDiagnostics> = std::thread::scope(|s| {
3165 (0..2)
3166 .map(|i| {
3167 s.spawn(move || {
3168 latexml_core::util::logger::capture(|| {
3169 Error!(
3170 "imageprocessing",
3171 "failed_to_convert",
3172 "Graphics: Failed to convert {} to {}",
3173 format!("w{i}.pdf"),
3174 format!("w{i}.png")
3175 );
3176 })
3177 .1
3178 })
3179 })
3180 .collect::<Vec<_>>()
3181 .into_iter()
3182 .map(|h| h.join().unwrap())
3183 .collect()
3184 });
3185
3186 // Fold the per-worker diagnostics into the main thread.
3187 for diags in captured {
3188 latexml_core::util::logger::replay_captured(diags);
3189 }
3190
3191 let after = latexml_core::common::error::snapshot_report_counts();
3192 let log = latexml_core::util::logger::flush_log();
3193
3194 assert!(
3195 log.contains("imageprocessing:failed_to_convert") && log.contains("Failed to convert"),
3196 "worker Error! must reach the main-thread log; got: {log:?}"
3197 );
3198 assert_eq!(
3199 after.error,
3200 before.error + 2,
3201 "both workers' Error! must increment the main REPORT error count via the fold"
3202 );
3203 }
3204
3205 /// `produced_output` is the guard that turns a `convert`/`gs` exit-0-but-no-file
3206 /// into a reported failure: a usable conversion is a non-empty file, not just a
3207 /// clean exit. Missing OR empty (0-byte) => not output.
3208 #[test]
3209 fn produced_output_requires_a_nonempty_file() {
3210 let tmp = std::env::temp_dir().join(format!("latexml_produced_{}", std::process::id()));
3211 std::fs::create_dir_all(&tmp).unwrap();
3212 let missing = tmp.join("missing.png");
3213 let empty = tmp.join("empty.png");
3214 let real = tmp.join("real.png");
3215 std::fs::write(&empty, b"").unwrap();
3216 std::fs::write(&real, b"\x89PNG").unwrap();
3217 assert!(
3218 !Graphics::produced_output(&missing.to_string_lossy()),
3219 "a missing file is not usable output"
3220 );
3221 assert!(
3222 !Graphics::produced_output(&empty.to_string_lossy()),
3223 "a 0-byte file is not usable output (would render as a broken image)"
3224 );
3225 assert!(
3226 Graphics::produced_output(&real.to_string_lossy()),
3227 "a non-empty file is usable output"
3228 );
3229 std::fs::remove_dir_all(&tmp).ok();
3230 }
3231
3232 /// A converter that fails must leave its stderr in the thread-local diagnostic,
3233 /// so the failed_to_convert Error can name WHY (e.g. gs `/undefinedfilename`).
3234 #[test]
3235 fn run_with_timeout_captures_stderr_into_diag() {
3236 take_converter_diag(); // clear
3237 let mut cmd = std::process::Command::new("sh");
3238 cmd.arg("-c").arg("echo 'boom on stderr' >&2; exit 3");
3239 let status = Graphics::run_with_timeout(cmd, std::time::Duration::from_secs(5));
3240 assert!(
3241 status.map(|s| !s.success()).unwrap_or(false),
3242 "command should report failure"
3243 );
3244 let diag = take_converter_diag().expect("a diagnostic must be recorded");
3245 assert!(
3246 diag.contains("boom on stderr") && diag.contains("sh"),
3247 "diag should name the program + its stderr; got: {diag}"
3248 );
3249 }
3250
3251 /// A converter that can't be spawned (not installed / not on PATH — e.g. `gs`
3252 /// missing in a minimal image) must record a "could not start" diagnostic.
3253 #[test]
3254 fn run_with_timeout_records_spawn_failure() {
3255 take_converter_diag();
3256 let cmd = std::process::Command::new("definitely_not_a_real_binary_xyz_123");
3257 let status = Graphics::run_with_timeout(cmd, std::time::Duration::from_secs(5));
3258 assert!(status.is_none(), "spawn failure returns None");
3259 let diag = take_converter_diag().expect("a spawn diagnostic must be recorded");
3260 assert!(
3261 diag.contains("could not start") && diag.contains("definitely_not_a_real_binary_xyz_123"),
3262 "diag should name the missing tool; got: {diag}"
3263 );
3264 }
3265
3266 /// Every graphics/image tool the cascade shells out to maps to an install
3267 /// hint that names the OS package, so a missing-dependency diagnostic is
3268 /// self-fixing; an unknown program yields no hint.
3269 #[test]
3270 fn missing_tool_hint_names_packages() {
3271 assert!(missing_tool_hint("mutool").unwrap().contains("mupdf-tools"));
3272 assert!(
3273 missing_tool_hint("pdftocairo")
3274 .unwrap()
3275 .contains("poppler-utils")
3276 );
3277 assert!(missing_tool_hint("gs").unwrap().contains("ghostscript"));
3278 assert!(missing_tool_hint("ps2pdf").unwrap().contains("ghostscript"));
3279 assert!(
3280 missing_tool_hint("convert")
3281 .unwrap()
3282 .contains("imagemagick")
3283 );
3284 assert!(missing_tool_hint("dvisvgm").unwrap().contains("dvisvgm"));
3285 assert!(missing_tool_hint("dvipng").unwrap().contains("dvipng"));
3286 assert!(missing_tool_hint("kpsewhich").unwrap().contains("TeX Live"));
3287 assert!(missing_tool_hint("some_unknown_tool").is_none());
3288 }
3289}