Skip to main content

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}