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}