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