Skip to main content

latexml_core/util/
image.rs

1//! Image helpers — port of `LaTeXML::Util::Image`.
2//!
3//! Perl counterpart: `lib/LaTeXML/Util/Image.pm`.
4//!
5//! Provides filesystem search for image candidates, minimal header-based
6//! image size detection (PNG / JPEG / EPS) and the graphicx `sizer` that
7//! converts keyval option strings into box dimensions. The Rust port is
8//! intentionally narrower than the Perl original — Image::Magick is not
9//! used at all; LaTeXML::Post::Graphics carries out any heavy-duty image
10//! operations in a post-processing pass.
11
12use std::path::{Path, PathBuf};
13
14use crate::{
15  BoxOps,
16  common::{dimension::Dimension, numeric_ops::NumericOps, store::Stored},
17  state,
18  whatsit::Whatsit,
19};
20
21/// Perl: `image_candidates($path)` (Util::Image L43-57).
22///
23/// Returns comma-separated list of candidate paths for `path`, searching
24/// GRAPHICSPATHS + SEARCHPATHS + SOURCEDIRECTORY. Paths are returned
25/// relative to SOURCEDIRECTORY when possible, matching the Perl
26/// `pathname_relative($_, $base)` post-filter.
27pub fn image_candidates(path: &str) -> String {
28  let path = path.trim().trim_matches('"');
29  if path.is_empty() {
30    return String::new();
31  }
32  let mut search_dirs: Vec<String> = state::get_graphics_paths();
33  search_dirs.extend(state::get_search_paths());
34  let source_dir = state::lookup_string("SOURCEDIRECTORY");
35  if !source_dir.is_empty() {
36    search_dirs.push(source_dir.clone());
37  }
38  if search_dirs.is_empty() {
39    search_dirs.push(".".to_string());
40  }
41
42  let mut candidates: Vec<String> = Vec::new();
43  let path_obj = Path::new(path);
44  let has_extension = path_obj.extension().is_some();
45  let source_path = if source_dir.is_empty() {
46    None
47  } else {
48    Some(PathBuf::from(&source_dir))
49  };
50
51  for dir in &search_dirs {
52    // Strip surrounding double-quotes from the search directory, symmetric to
53    // the `path.trim_matches('"')` above. A quoted `\graphicspath{{"./dir"}}`
54    // (or `\svgpath` / `--graphicspaths`) otherwise joins to a `"…"` path that
55    // never resolves. See OXIDIZED_DESIGN #55.
56    let dir = dir.trim().trim_matches('"');
57    let base = PathBuf::from(dir).join(path);
58    if has_extension {
59      if base.exists() {
60        // Perl relativizes every hit to SOURCEDIRECTORY via pathname_relative
61        // (→ File::Spec->abs2rel), which emits a `../…` path for a graphic in a
62        // SIBLING directory (issue #698: `\subimport*{../gfx_asset/}` reaching a
63        // sideways tree). See `pathname::relative`, which now matches Perl (it
64        // used to leak the absolute path on a non-descendant hit).
65        let rel = match &source_path {
66          Some(sp) => {
67            crate::util::pathname::relative(&base.to_string_lossy(), &sp.to_string_lossy())
68          },
69          None => base.to_string_lossy().to_string(),
70        };
71        candidates.push(rel);
72      }
73    } else {
74      // Search for path with any extension
75      let parent = base.parent().unwrap_or_else(|| Path::new("."));
76      let stem = base
77        .file_name()
78        .map(|s| s.to_string_lossy().to_string())
79        .unwrap_or_default();
80      if let Ok(entries) = std::fs::read_dir(parent) {
81        for entry in entries.flatten() {
82          let fname = entry.file_name().to_string_lossy().to_string();
83          if let Some(dot_pos) = fname.find('.')
84            && fname[..dot_pos] == stem
85          {
86            let full = entry.path();
87            // Sibling-directory relativization (issue #698) — see the
88            // extension branch above: pathname::relative (abs2rel semantics).
89            let rel = match &source_path {
90              Some(sp) => {
91                crate::util::pathname::relative(&full.to_string_lossy(), &sp.to_string_lossy())
92              },
93              None => full.to_string_lossy().to_string(),
94            };
95            candidates.push(rel);
96          }
97        }
98      }
99    }
100  }
101
102  // Perl image_candidates (Util/Image.pm L49-53): when the search-dir lookup
103  // finds nothing AND the name is extensionless, consult kpsewhich for
104  // `<path>.png` / `<path>.pdf` — this resolves TeX Live system images such as
105  // `example-image-a` (whose real file is a .pdf). Crucially, kpsewhich returns
106  // ONLY files that actually exist, so a missing image yields no candidate. The
107  // earlier Rust port instead SYNTHESIZED `<path>.png` unconditionally, so a
108  // missing extensionless image got a bogus `candidates="missing.png"` (Perl
109  // emits none) and `example-image-a` got the wrong `.png` instead of its `.pdf`.
110  if candidates.is_empty() && !has_extension {
111    let png = format!("{path}.png");
112    let pdf = format!("{path}.pdf");
113    if let Some(found) = crate::util::pathname::kpsewhich(&[&png, &pdf]) {
114      // Perl relativizes every candidate to SOURCEDIRECTORY via pathname_relative,
115      // which yields a `../…`-style path for a kpsewhich hit in the texmf tree
116      // (e.g. `../usr/share/texlive/…/example-image-a.png`) — NOT an absolute
117      // machine path. `pathname::relative` now emits that `../…` form for a
118      // non-descendant tree (issue #698 fixed its strip_prefix leak).
119      let rel = match &source_path {
120        Some(sp) => crate::util::pathname::relative(&found, &sp.to_string_lossy()),
121        None => found,
122      };
123      candidates.push(rel);
124    }
125  }
126
127  // Deduplicate while preserving order
128  let mut seen = rustc_hash::FxHashSet::default();
129  candidates.retain(|c| seen.insert(c.clone()));
130
131  // Perl image_candidates (Util/Image.pm) returns ($path, @candidates) where
132  // @candidates holds only files actually found (pathname_findall + kpsewhich);
133  // graphicx.sty sets `candidates => join(',', @candidates)`, so a missing file
134  // yields an EMPTY candidates string (the attribute is then omitted) while the
135  // `graphic` attribute still carries the raw path. The earlier Rust port fell
136  // back to the raw path here, emitting `candidates="missing.png"` where Perl
137  // emits no candidates at all. Return empty to match.
138  candidates.join(",")
139}
140
141/// One graphicx transformation, as compiled from the option string.
142///
143/// Port of the `@transform` list Perl `image_graphicx_parse` builds
144/// (`Util/Image.pm` L142-196). Lengths are in **bp**, the unit `to_bp` yields,
145/// and angles in degrees counter-clockwise, as graphicx states them.
146#[derive(Debug, Clone, PartialEq)]
147pub enum GraphicxOp {
148  /// `page=N` — which page of a multi-page source to take.
149  Page(u32),
150  /// `trim=l b r t` — amounts to remove from each edge.
151  Trim {
152    l: f64,
153    b: f64,
154    r: f64,
155    t: f64,
156  },
157  /// `viewport=llx lly urx ury` — an absolute box (Perl's `clip` op).
158  Clip {
159    l: f64,
160    b: f64,
161    r: f64,
162    t: f64,
163  },
164  /// `angle=N`, counter-clockwise.
165  Rotate(f64),
166  Reflect,
167  /// `scale=`/`xscale=`/`yscale=`.
168  Scale {
169    x: f64,
170    y: f64,
171  },
172  /// `width=`/`height=`/`totalheight=`. A dimension left `None` is derived
173  /// from the other through the aspect ratio; Perl spells that as a 999999
174  /// sentinel with `keep_aspect` forced on (L188-189).
175  ScaleTo {
176    w:           Option<f64>,
177    h:           Option<f64>,
178    keep_aspect: bool,
179  },
180}
181
182/// A TeX/graphicx length in **bp**. Port of Perl `to_bp` + `%BP_conversions`
183/// (`Util/Image.pm` L198-210), including its `true`-prefix strip (`truept`) and
184/// its "unknown unit counts as bp" fallback. A value that is not a length at
185/// all yields 1, exactly as Perl's `else { return 1 }` does.
186pub fn to_bp(x: &str) -> f64 {
187  let x = x.trim();
188  let split = x
189    .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '+' && c != '-')
190    .unwrap_or(x.len());
191  let (num, unit) = x.split_at(split);
192  let Ok(v) = num.parse::<f64>() else {
193    return 1.0;
194  };
195  let unit = unit.trim().strip_prefix("true").unwrap_or(unit.trim());
196  let factor = match unit {
197    "" | "bp" => 1.0,
198    "pt" => 72.0 / 72.27,
199    "pc" => 12.0 * 72.0 / 72.27,
200    "in" => 72.0,
201    "cm" => 72.0 / 2.54,
202    "mm" => 72.0 / 25.4,
203    "dd" => (72.0 / 72.27) * (1238.0 / 1157.0),
204    "cc" => 12.0 * (72.0 / 72.27) * (1238.0 / 1157.0),
205    "sp" => 72.0 / 72.27 / 65536.0,
206    // Perl: `($u && $BP_conversions{$u}) || 1` — an unrecognised unit falls
207    // back to a factor of 1, i.e. the number is taken as bp.
208    _ => 1.0,
209  };
210  v * factor
211}
212
213/// Compile a graphicx option string into the transformation sequence.
214///
215/// Port of Perl `image_graphicx_parse` (`Util/Image.pm` L142-196). Key order
216/// matters and is Perl's, in two ways:
217///
218/// * A rotation is applied **before** scaling when no sizing option preceded
219///   the `angle` in the source string, and after it otherwise. Perl decides
220///   this the instant it parses `angle` (`$rotfirst = !($width || $height ||
221///   $xscale || $yscale)`, L168), from the keys seen *so far* — so
222///   `angle=90,width=100pt` rotates then scales, while `width=100pt,angle=90`
223///   scales then rotates. graphicx really behaves this way and pdflatex agrees:
224///   the first is ~100x200, the second ~50x100 for a 200x100 source. We capture
225///   `rot_first` at the same point, not from the final key set.
226///
227/// `pc` differs from Perl by design: Perl's table has `pc => 12/72.27`, which
228/// is 12 *TeX pt* expressed in bp only if you also drop the pt→bp step — a pica
229/// is 12 pt, so the factor is `12 * 72/72.27`. Perl's value makes a 1pc box
230/// 0.166bp instead of 11.955bp. Ours is the correct one; no test in the corpus
231/// exercised `pc`.
232pub fn parse_graphicx_options(options: &str) -> Vec<GraphicxOp> {
233  let (mut width, mut height) = (None, None);
234  let (mut xscale, mut yscale) = (None, None);
235  let (mut aspect, mut angle, mut page) = (false, 0.0f64, None);
236  let (mut viewport, mut is_trim) = (None, false);
237  // Set the instant `angle` is parsed, from the sizing keys seen so far — NOT
238  // recomputed from the final key set. Perl `image_graphicx_parse` L168.
239  let mut rot_first = false;
240  for opt in options.split(',') {
241    let opt = opt.trim();
242    if opt.is_empty() {
243      continue;
244    }
245    let (key, val) = match opt.split_once('=') {
246      Some((k, v)) => (k.trim(), v.trim()),
247      None => (opt, ""),
248    };
249    let box4 = |v: &str| {
250      let n: Vec<f64> = v.split_whitespace().map(to_bp).collect();
251      if n.len() == 4 {
252        Some((n[0], n[1], n[2], n[3]))
253      } else {
254        None
255      }
256    };
257    match key {
258      "width" => width = Some(to_bp(val)),
259      "height" | "totalheight" => height = Some(to_bp(val)),
260      "scale" => {
261        let s = val.parse::<f64>().ok();
262        xscale = s;
263        yscale = s;
264      },
265      "xscale" => xscale = val.parse::<f64>().ok(),
266      "yscale" => yscale = val.parse::<f64>().ok(),
267      "angle" => {
268        angle = val.parse::<f64>().unwrap_or(0.0);
269        rot_first = width.is_none() && height.is_none() && xscale.is_none() && yscale.is_none();
270      },
271      "keepaspectratio" => aspect = val != "false",
272      "page" => page = val.parse::<u32>().ok(),
273      "viewport" => {
274        viewport = box4(val);
275        is_trim = false;
276      },
277      "trim" => {
278        viewport = box4(val);
279        is_trim = true;
280      },
281      _ => {},
282    }
283  }
284
285  let mut ops = Vec::new();
286  if let Some(p) = page {
287    ops.push(GraphicxOp::Page(p));
288  }
289  if let Some((a, b, c, d)) = viewport {
290    ops.push(if is_trim {
291      GraphicxOp::Trim { l: a, b, r: c, t: d }
292    } else {
293      GraphicxOp::Clip { l: a, b, r: c, t: d }
294    });
295  }
296  if rot_first && angle != 0.0 {
297    ops.push(GraphicxOp::Rotate(angle));
298  }
299  match (width, height, xscale, yscale) {
300    // Perl L187-189: a single dimension forces aspect preservation, whatever
301    // `keepaspectratio` said.
302    (Some(w), Some(h), ..) => ops.push(GraphicxOp::ScaleTo {
303      w:           Some(w),
304      h:           Some(h),
305      keep_aspect: aspect,
306    }),
307    (Some(w), None, ..) => ops.push(GraphicxOp::ScaleTo {
308      w:           Some(w),
309      h:           None,
310      keep_aspect: true,
311    }),
312    (None, Some(h), ..) => ops.push(GraphicxOp::ScaleTo {
313      w:           None,
314      h:           Some(h),
315      keep_aspect: true,
316    }),
317    (None, None, Some(x), Some(y)) => ops.push(GraphicxOp::Scale { x, y }),
318    (None, None, Some(x), None) => ops.push(GraphicxOp::Scale { x, y: 1.0 }),
319    (None, None, None, Some(y)) => ops.push(GraphicxOp::Scale { x: 1.0, y }),
320    (None, None, None, None) => {},
321  }
322  if !rot_first && angle != 0.0 {
323    ops.push(GraphicxOp::Rotate(angle));
324  }
325  ops
326}
327
328/// Apply a compiled transformation sequence to a natural size.
329///
330/// Port of Perl `image_graphicx_size` (`Util/Image.pm` L221-256), generalised
331/// over the output unit so the engine and the post-processor share one algebra:
332///
333/// * `units_per_bp` scales a bp-valued option into the caller's unit —
334///   `DPI/72.27` for device pixels (Perl's `$dppt`), `72.27/72` for TeX pt.
335/// * `quantize` applies Perl's `ceil` at each sizing step. True in pixel space,
336///   where a fractional device pixel is meaningless; false in pt space, where
337///   rounding the box to 1/100 inch would be a needless loss of precision.
338///
339/// `Page` is a selector, not a geometric transform, so it is skipped here —
340/// callers read it out separately.
341pub fn apply_graphicx_ops(
342  mut w: f64,
343  mut h: f64,
344  ops: &[GraphicxOp],
345  units_per_bp: f64,
346  quantize: bool,
347) -> (f64, f64) {
348  let round = |v: f64| if quantize { v.ceil() } else { v };
349  for op in ops {
350    match *op {
351      GraphicxOp::Page(_) | GraphicxOp::Reflect => {},
352      GraphicxOp::Scale { x, y } => {
353        w = round(w * x);
354        h = round(h * y);
355      },
356      GraphicxOp::ScaleTo { w: rw, h: rh, keep_aspect } => {
357        let (tw, th) = (rw.map(|v| v * units_per_bp), rh.map(|v| v * units_per_bp));
358        match (tw, th) {
359          (Some(tw), Some(th)) if keep_aspect => {
360            // Perl L234 `return unless $w && $h` — a degenerate natural size
361            // carries no aspect ratio to preserve, and Perl abandons the whole
362            // computation rather than guess. The sizer then reports 0.
363            if w <= 0.0 || h <= 0.0 {
364              return (0.0, 0.0);
365            }
366            // Perl L233-236: honour the less extreme request, so the result
367            // fits inside the requested box.
368            if tw / w < th / h {
369              h = h * tw / w;
370              w = tw;
371            } else {
372              w = w * th / h;
373              h = th;
374            }
375            w = round(w);
376            h = round(h);
377          },
378          (Some(tw), Some(th)) => {
379            w = round(tw);
380            h = round(th);
381          },
382          // A single dimension always preserves aspect (Perl compiles it as a
383          // scale-to with a 999999 sentinel and `keep_aspect` forced on), so
384          // the same degenerate-size bail applies.
385          (Some(tw), None) => {
386            if w <= 0.0 || h <= 0.0 {
387              return (0.0, 0.0);
388            }
389            h = round(h * tw / w);
390            w = round(tw);
391          },
392          (None, Some(th)) => {
393            if w <= 0.0 || h <= 0.0 {
394              return (0.0, 0.0);
395            }
396            w = round(w * th / h);
397            h = round(th);
398          },
399          (None, None) => {},
400        }
401      },
402      GraphicxOp::Rotate(deg) => {
403        // Perl L239-242: `$rad = -$a1 * pi/180`, then the axis-aligned bounding
404        // box of the rotated rectangle. Not quantized — Perl does not ceil here.
405        let rad = -deg * std::f64::consts::PI / 180.0;
406        let (s, c) = (rad.sin(), rad.cos());
407        let (nw, nh) = ((w * c).abs() + (h * s).abs(), (w * s).abs() + (h * c).abs());
408        w = nw;
409        h = nh;
410      },
411      GraphicxOp::Trim { l, b, r, t } => {
412        // Perl L248-250: shrink by the trimmed edges.
413        w = round(w - (l + r) * units_per_bp);
414        h = round(h - (t + b) * units_per_bp);
415      },
416      GraphicxOp::Clip { l, b, r, t } => {
417        // Perl L252-253: the viewport box IS the new extent.
418        w = round((r - l) * units_per_bp);
419        h = round((t - b) * units_per_bp);
420      },
421    }
422  }
423  (w.max(0.0), h.max(0.0))
424}
425
426/// Perl: `image_graphicx_sizer($whatsit)` (Util::Image L259-272).
427///
428/// Reads image dimensions from `candidates`, applies the `options` string
429/// (graphicx keyvals: width/height/totalheight/scale/keepaspectratio) and
430/// writes back `cached_width`, `cached_height`, `cached_depth` on the
431/// whatsit so downstream getSize() consumers (pgf, tikz) see the correct
432/// box dimensions.
433pub fn image_graphicx_sizer(whatsit: &mut Whatsit) {
434  let dpi_val = state::lookup_int("DPI");
435  let dpi = if dpi_val > 0 { dpi_val as f64 } else { 100.0 }; // Perl: our $DPI = 100
436  let candidates = whatsit
437    .get_property("candidates")
438    .map(|c| c.to_string())
439    .unwrap_or_default();
440  let options = whatsit
441    .get_property("options")
442    .map(|c| c.to_string())
443    .unwrap_or_default();
444
445  // Try to read actual image dimensions from file
446  let mut img_w: f64 = 0.0;
447  let mut img_h: f64 = 0.0;
448  let source_dir = state::lookup_string("SOURCEDIRECTORY");
449  for candidate in candidates.split(',') {
450    let candidate = candidate.trim();
451    if candidate.is_empty() {
452      continue;
453    }
454    let full_path = if Path::new(candidate).is_absolute() {
455      PathBuf::from(candidate)
456    } else if !source_dir.is_empty() {
457      PathBuf::from(&source_dir).join(candidate)
458    } else {
459      PathBuf::from(candidate)
460    };
461    if let Some((w, h)) = read_image_dimensions(&full_path) {
462      img_w = w as f64;
463      img_h = h as f64;
464      break;
465    }
466  }
467
468  if img_w <= 0.0 || img_h <= 0.0 {
469    // The raster readers (PNG/JPEG/EPS, like Perl's `imgsize`) couldn't measure
470    // the asset. Before giving up, emulate pdfTeX: read the natural size from the
471    // file itself. pdfTeX's built-in reader takes a PDF's CropBox (its default)
472    // or MediaBox, and an SVG's viewBox — with NO external tool. (Perl-LaTeXML
473    // instead shells out to ImageMagick precisely because Image::Size can't read
474    // PDF; even then it forces `pdf:use-cropbox` to match pdfTeX. So the faithful,
475    // self-contained move is pdfTeX's, not Perl's.) `natural_size_pt` shares the
476    // same CropBox→MediaBox reader as `LaTeXML::Post::Graphics`.
477    //
478    // Whatever we decide, we MUST set `cached_width`: without it, `compute_size`
479    // falls through to summing the whatsit's ARGUMENT boxes — and one of them is
480    // the Semiverbatim *filename* — so a bare `arrange_panels` would wrap figure
481    // rows by path length (arXiv:2409.16471 fig 2: 12 uniform 0.245\textwidth
482    // panels split 3/3/2/3/1 by filename, not 3 rows of 4).
483    let source_dir = state::lookup_string("SOURCEDIRECTORY");
484    let natural = candidates.split(',').find_map(|candidate| {
485      let candidate = candidate.trim();
486      if candidate.is_empty() {
487        return None;
488      }
489      natural_size_pt(&resolve_candidate(candidate, &source_dir))
490    });
491    if let Some((nw_pt, nh_pt)) = natural {
492      // pdfTeX/graphics.sty box sizing in pt (verified against `\the\wd` under
493      // pdflatex): with an explicit `width=`, the box width IS the request and
494      // the natural size only fills in the height via the aspect ratio.
495      let (bw, bh) = graphicx_box_pt(nw_pt, nh_pt, &options);
496      whatsit.set_property("cached_width", Stored::Dimension(bw));
497      whatsit.set_property("cached_height", Stored::Dimension(bh));
498      whatsit.set_property("cached_depth", Stored::Dimension(Dimension::default()));
499      return;
500    }
501    // Last resort — a PDF whose page box is buried in a compressed object stream
502    // (where pdfTeX's full parser would still succeed but our byte reader can't),
503    // or an unreadable SVG. Honor an EXPLICIT `width=`/`height=` request (the
504    // display size LaTeXML already emits), else 0 (Perl-without-ImageMagick
505    // parity). Still set `cached_width` so the filename is never summed.
506    let mut ew: Option<Dimension> = None;
507    let mut eh: Option<Dimension> = None;
508    for opt in options.split(',') {
509      let opt = opt.trim();
510      if let Some(val) = opt.strip_prefix("width=") {
511        ew = <Dimension as std::str::FromStr>::from_str(val.trim()).ok();
512      } else if let Some(val) = opt.strip_prefix("height=") {
513        eh = <Dimension as std::str::FromStr>::from_str(val.trim()).ok();
514      } else if let Some(val) = opt.strip_prefix("totalheight=") {
515        eh = <Dimension as std::str::FromStr>::from_str(val.trim()).ok();
516      }
517    }
518    whatsit.set_property("cached_width", Stored::Dimension(ew.unwrap_or_default()));
519    whatsit.set_property("cached_height", Stored::Dimension(eh.unwrap_or_default()));
520    whatsit.set_property("cached_depth", Stored::Dimension(Dimension::default()));
521    return;
522  }
523
524  // Apply graphicx options (height, width, scale, keepaspectratio)
525  // Perl: image_graphicx_size applies parsed transformations
526  // Perl `image_graphicx_size` (Util/Image.pm L221-256) works in device pixels
527  // with `$dppt = DPI/72.27`, and derives the box from it at L271.
528  let (w, h) = apply_graphicx_ops(
529    img_w,
530    img_h,
531    &parse_graphicx_options(&options),
532    dpi / 72.27,
533    true,
534  );
535
536  // Convert pixel dimensions back to points, then to scaled points (sp)
537  let width_pt = w * 72.27 / dpi;
538  let height_pt = h * 72.27 / dpi;
539
540  // Perl: Dimension($w * 72.27 / $dpi . 'pt') — parses via TeX fixed-point arithmetic
541  let w_dim =
542    <Dimension as std::str::FromStr>::from_str(&format!("{width_pt}pt")).unwrap_or_default();
543  let h_dim =
544    <Dimension as std::str::FromStr>::from_str(&format!("{height_pt}pt")).unwrap_or_default();
545  whatsit.set_property("cached_width", Stored::Dimension(w_dim));
546  whatsit.set_property("cached_height", Stored::Dimension(h_dim));
547  whatsit.set_property("cached_depth", Stored::Dimension(Dimension::default()));
548}
549
550/// Run a fallible I/O op, retrying on a *transient* lock. On Windows a
551/// just-written file — a figure the converter emitted a moment ago, or a test
552/// fixture — can be momentarily locked by another handle (antivirus real-time
553/// scanning of the fresh file, or Windows' stricter default file sharing). A
554/// bare `op().ok()?` would turn that into a silent `None`, and a figure would
555/// reach the engine at 0x0.
556///
557/// A genuine `NotFound` is not a lock, so it fails fast. Every *other* error is
558/// treated as possibly-transient and retried with a widening backoff up to
559/// ~0.5 s total. The fresh-file lock usually surfaces as `PermissionDenied`
560/// (`ERROR_SHARING_VIOLATION`), but under heavy parallel load (a full `cargo
561/// test` with antivirus active) it has shown other kinds and needed longer than
562/// a few ms to clear — so this deliberately retries broadly rather than gating on
563/// one `ErrorKind`. A permanently-unreadable path pays the full budget once and
564/// then fails; the happy path (Ok on the first try) pays nothing.
565fn with_transient_retry<T>(mut op: impl FnMut() -> std::io::Result<T>) -> Option<T> {
566  let mut tries = 0u32;
567  loop {
568    match op() {
569      Ok(v) => return Some(v),
570      Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
571      Err(_) if tries < 10 => {
572        tries += 1;
573        std::thread::sleep(std::time::Duration::from_millis(u64::from(tries) * 10));
574      },
575      Err(_) => return None,
576    }
577  }
578}
579
580/// [`std::fs::read`] with the transient-lock retry of [`with_transient_retry`].
581fn read_file_resilient(path: &Path) -> Option<Vec<u8>> {
582  with_transient_retry(|| std::fs::read(path))
583}
584
585/// [`std::fs::File::open`] with the transient-lock retry of
586/// [`with_transient_retry`]. Only the open races with the scanner/writer; reads
587/// on the returned handle do not, so callers keep their streaming reads.
588fn open_file_resilient(path: &Path) -> Option<std::fs::File> {
589  with_transient_retry(|| std::fs::File::open(path))
590}
591
592/// Read image dimensions (width, height) in pixels from a file.
593/// Supports PNG, JPEG, and EPS (PostScript BoundingBox).
594///
595/// This is a narrow replacement for `Image::Size::imgsize` (Perl
596/// `image_size` at Util::Image L86-97). Only a few formats are needed
597/// for typical arXiv graphics inclusions — anything else returns `None`
598/// so the caller skips sizing (mirroring Perl's `return unless $w`).
599pub fn read_image_dimensions(path: &Path) -> Option<(u32, u32)> {
600  use std::io::Read;
601  let mut file = open_file_resilient(path)?;
602  let mut header = [0u8; 32];
603  file.read_exact(&mut header).ok()?;
604
605  // PNG: signature + IHDR chunk
606  if &header[0..8] == b"\x89PNG\r\n\x1a\n" {
607    let width = u32::from_be_bytes([header[16], header[17], header[18], header[19]]);
608    let height = u32::from_be_bytes([header[20], header[21], header[22], header[23]]);
609    return Some((width, height));
610  }
611
612  // JPEG: look for SOF marker
613  if header[0] == 0xFF && header[1] == 0xD8 {
614    // Read the full file for JPEG parsing
615    let mut data = header.to_vec();
616    file.read_to_end(&mut data).ok()?;
617    let mut i = 2;
618    while i + 9 < data.len() {
619      if data[i] != 0xFF {
620        break;
621      }
622      let marker = data[i + 1];
623      // SOF markers: 0xC0-0xCF (except 0xC4 DHT, 0xC8 JPG, 0xCC DAC)
624      if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
625        let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
626        let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
627        return Some((width, height));
628      }
629      let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
630      i += 2 + len;
631    }
632  }
633
634  // EPS: PostScript BoundingBox comment. Perl: LaTeXML::Util::Image reads
635  // the leading `%%BoundingBox: llx lly urx ury` (values in bp, 1bp=1/72").
636  // `%%HiResBoundingBox:` is preferred when present (float precision). We
637  // read the first ~8KB since BoundingBox can be deferred (`(atend)` form
638  // is also valid but would require scanning the tail; skip that).
639  if (header[0] == b'%' && (header[1] == b'!' || header[1] == b'%'))
640    || (header.starts_with(b"\xc5\xd0\xd3\xc6"))
641  // EPS with binary preview header
642  {
643    let mut data = header.to_vec();
644    // Read up to 32KB — BoundingBox typically in first few hundred bytes
645    let mut extra = [0u8; 32768];
646    let n = file.read(&mut extra).ok().unwrap_or(0);
647    data.extend_from_slice(&extra[..n]);
648    // If DOS EPSI binary preview: first 4 bytes are C5 D0 D3 C6, next 4
649    // little-endian is offset to the PostScript section. Skip to it.
650    let text_start = if data.starts_with(b"\xc5\xd0\xd3\xc6") && data.len() >= 8 {
651      u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize
652    } else {
653      0
654    };
655    let text = std::str::from_utf8(data.get(text_start..)?).ok()?;
656    // Prefer HiResBoundingBox (float) over BoundingBox (int).
657    let mut found: Option<(f64, f64, f64, f64)> = None;
658    for line in text.lines() {
659      let trimmed = line.trim_start();
660      let rest = if let Some(r) = trimmed.strip_prefix("%%HiResBoundingBox:") {
661        // HiRes wins — take and stop searching.
662        parse_bbox(r).inspect(|&b| {
663          found = Some(b);
664        })
665      } else if found.is_none() {
666        trimmed
667          .strip_prefix("%%BoundingBox:")
668          .and_then(parse_bbox)
669          .inspect(|&b| {
670            found = Some(b);
671          })
672      } else {
673        None
674      };
675      if rest.is_some() && trimmed.starts_with("%%HiResBoundingBox:") {
676        break;
677      }
678    }
679    if let Some((llx, lly, urx, ury)) = found {
680      let w = (urx - llx).max(0.0);
681      let h = (ury - lly).max(0.0);
682      if w > 0.0 && h > 0.0 {
683        // EPS BoundingBox is in bp (1bp = 1/72"). Return as pixels at the
684        // same bp-per-pixel rate the caller expects (it divides by dppt =
685        // dpi/72.27 downstream). Using 1:1 means callers get bp-sized
686        // pixels, consistent with Perl's `image_size` returning bp for
687        // EPS (LaTeXML::Util::Image::image_size L45-L60).
688        return Some((w.round() as u32, h.round() as u32));
689      }
690    }
691  }
692
693  None
694}
695
696/// Parse `"llx lly urx ury"` from a BoundingBox comment body.
697pub fn parse_bbox(rest: &str) -> Option<(f64, f64, f64, f64)> {
698  let mut it = rest.split_whitespace();
699  let llx = it.next()?.parse::<f64>().ok()?;
700  let lly = it.next()?.parse::<f64>().ok()?;
701  let urx = it.next()?.parse::<f64>().ok()?;
702  let ury = it.next()?.parse::<f64>().ok()?;
703  Some((llx, lly, urx, ury))
704}
705
706/// Resolve an `image_candidates` entry to a filesystem path, relative to the
707/// document's `SOURCEDIRECTORY` when the candidate isn't already absolute.
708fn resolve_candidate(candidate: &str, source_dir: &str) -> PathBuf {
709  if Path::new(candidate).is_absolute() {
710    PathBuf::from(candidate)
711  } else if !source_dir.is_empty() {
712    PathBuf::from(source_dir).join(candidate)
713  } else {
714    PathBuf::from(candidate)
715  }
716}
717
718/// Natural (unscaled) size of a graphic in TeX points, read the way pdfTeX
719/// reads it — with no external tool: a PDF's CropBox (default) / MediaBox, or an
720/// SVG's width/height / viewBox. `None` for formats the raster readers already
721/// handle, or when the geometry can't be recovered (e.g. a PDF whose page box is
722/// hidden inside a compressed object stream).
723fn natural_size_pt(path: &Path) -> Option<(f64, f64)> {
724  if let Some((w_bp, h_bp)) = read_pdf_page_box(path) {
725    return Some((bp_to_pt(w_bp), bp_to_pt(h_bp)));
726  }
727  read_svg_size_pt(path)
728}
729
730/// bp (PostScript big point, 1/72") → TeX pt (1/72.27").
731fn bp_to_pt(bp: f64) -> f64 { bp * 72.27 / 72.0 }
732
733/// The figure's TRUE natural (typeset) size in TeX pt, for the VECTOR formats
734/// whose intrinsic size is a real physical dimension: a PDF page box, an EPS/PS
735/// `%%BoundingBox` (both bp), or an SVG's lengths/viewBox. `None` for raster
736/// formats — a pixel count is not a physical size without a DPI — and when the
737/// geometry can't be recovered.
738///
739/// This is deliberately NOT `image_graphicx_sizer`'s `cached_width`: that runs
740/// EPS/raster dimensions through a device-DPI round-trip (`×72.27/DPI`), which is
741/// right for the box model's device-pixel sizing but wrong as a physical length.
742/// This function is the size a browser should reproduce, used for the
743/// font-relative (`em`) sizing of natural-size figure inclusions (#562).
744/// Extension-gated so each format is read exactly once; pure Rust, no external
745/// tool.
746pub fn natural_display_size_pt(path: &Path) -> Option<(f64, f64)> {
747  let ext = path
748    .extension()
749    .and_then(|e| e.to_str())
750    .map(|e| e.to_ascii_lowercase());
751  match ext.as_deref() {
752    Some("pdf") => read_pdf_page_box(path).map(|(w, h)| (bp_to_pt(w), bp_to_pt(h))),
753    // read_image_dimensions returns an EPS/PS BoundingBox 1:1 in bp.
754    Some("eps" | "ps" | "epsi" | "epsf") => read_image_dimensions(path)
755      .filter(|&(w, h)| w > 0 && h > 0)
756      .map(|(w, h)| (bp_to_pt(w as f64), bp_to_pt(h as f64))),
757    Some("svg" | "svgz") => read_svg_size_pt(path),
758    _ => None,
759  }
760}
761
762/// [`natural_display_size_pt`] over a comma-joined `candidates` string (the
763/// `<ltx:graphics candidates=…>` attribute), resolving each candidate against
764/// `source_dir` and returning the first that yields a size.
765pub fn natural_display_size_pt_of_candidates(
766  candidates: &str,
767  source_dir: &str,
768) -> Option<(f64, f64)> {
769  candidates.split(',').find_map(|c| {
770    let c = c.trim();
771    (!c.is_empty())
772      .then(|| natural_display_size_pt(&resolve_candidate(c, source_dir)))
773      .flatten()
774  })
775}
776
777/// pt (f64) → `Dimension` (scaled points).
778fn pt_to_dim(pt: f64) -> Dimension { Dimension::new((pt * 65536.0).round() as i64) }
779
780/// Apply graphicx `width`/`height`/`totalheight`/`scale`/`keepaspectratio` to a
781/// natural (pt) size, matching pdfTeX/graphics.sty box sizing. Verified against
782/// `\the\wd` under pdflatex: an explicit `width=` sets the box width outright,
783/// the natural size only supplying the missing dimension via the aspect ratio.
784fn graphicx_box_pt(nw: f64, nh: f64, options: &str) -> (Dimension, Dimension) {
785  // The same algebra as the pixel branch, in pt and without quantization:
786  // options arrive in bp, and 1bp = 72.27/72 pt. Rounding a typeset box to a
787  // whole device pixel — which is what the pixel branch's `ceil` amounts to —
788  // would throw away four digits of a TeX dimension for nothing.
789  let (bw, bh) = apply_graphicx_ops(
790    nw,
791    nh,
792    &parse_graphicx_options(options),
793    72.27 / 72.0,
794    false,
795  );
796  (pt_to_dim(bw), pt_to_dim(bh))
797}
798
799/// Read a PDF's page box (width, height) in bp — CropBox (pdfTeX's default),
800/// else MediaBox. Pure Rust, no external tool (this is what pdfTeX's built-in
801/// reader does). Shared with `LaTeXML::Post::Graphics`.
802///
803/// Looks in the raw bytes first, then inside object streams. `%PDF-1.5` and
804/// later — everything current pdflatex emits — may put the page tree in a
805/// `/Type /ObjStm` stream, where the box tokens do not appear as raw bytes at
806/// all: measured over 14 real PDFs in this repo, 5 were unreadable without this
807/// second pass, and `ObjStm` presence predicted it exactly.
808///
809/// **First box wins**, in file order, as the raw-byte scan has always done. A
810/// correct answer for page N would mean resolving the page tree through the
811/// xref stream; for the figures `\includegraphics` pulls in, which are
812/// single-page, the first box is the page's own (or the `/Pages` node's, which
813/// it inherits).
814pub fn read_pdf_page_box(path: &Path) -> Option<(f64, f64)> {
815  let bytes = read_file_resilient(path)?;
816  if byte_find(&bytes, b"/CropBox").is_some() || byte_find(&bytes, b"/MediaBox").is_some() {
817    let content = String::from_utf8_lossy(&bytes);
818    if let Some(box_) =
819      parse_pdf_box(&content, "/CropBox").or_else(|| parse_pdf_box(&content, "/MediaBox"))
820    {
821      return Some(box_);
822    }
823  }
824  let inflated = inflate_object_streams(&bytes)?;
825  parse_pdf_box(&inflated, "/CropBox").or_else(|| parse_pdf_box(&inflated, "/MediaBox"))
826}
827
828/// Concatenate the inflated contents of every `/Type /ObjStm` in `bytes`.
829///
830/// Deliberately not a PDF parser: it finds object-stream dictionaries, takes the
831/// `stream`…`endstream` payload that follows each, and inflates it. That is
832/// enough to expose the page dictionary, and it stops well short of xref-stream
833/// parsing and object resolution — which is what a real page-N lookup would
834/// need, and is not what a figure's natural size is worth.
835///
836/// Only `/FlateDecode` streams are attempted (the only filter pdflatex, Ghost-
837/// script, Cairo or matplotlib use for object streams), and only the first
838/// [`MAX_OBJSTM_SCAN`] of them, so a pathological file cannot turn a size probe
839/// into an unbounded decompression.
840fn inflate_object_streams(bytes: &[u8]) -> Option<String> {
841  use std::io::Read;
842
843  /// Enough for any real document; a figure PDF has one or two.
844  const MAX_OBJSTM_SCAN: usize = 64;
845  /// Per-stream inflate ceiling, so a zip bomb cannot be handed to us as a
846  /// figure. A page dictionary is a few hundred bytes.
847  const MAX_INFLATED: u64 = 8 << 20;
848
849  let mut out = String::new();
850  let mut from = 0;
851  let mut seen = 0;
852  while seen < MAX_OBJSTM_SCAN {
853    let Some(hit) = byte_find(&bytes[from..], b"/ObjStm") else {
854      break;
855    };
856    let at = from + hit;
857    from = at + b"/ObjStm".len();
858    seen += 1;
859    // The dictionary ends at `stream`, optionally followed by CR, then LF.
860    let Some(rel) = byte_find(&bytes[at..], b"stream") else {
861      continue;
862    };
863    let dict = &bytes[at..at + rel];
864    if byte_find(dict, b"/FlateDecode").is_none() {
865      continue;
866    }
867    let mut start = at + rel + b"stream".len();
868    if bytes.get(start) == Some(&b'\r') {
869      start += 1;
870    }
871    if bytes.get(start) == Some(&b'\n') {
872      start += 1;
873    }
874    let end = byte_find(&bytes[start..], b"endstream").map_or(bytes.len(), |e| start + e);
875    let mut buf = Vec::new();
876    if flate2::read::ZlibDecoder::new(&bytes[start..end])
877      .take(MAX_INFLATED)
878      .read_to_end(&mut buf)
879      .is_err()
880      && buf.is_empty()
881    {
882      // A truncated or mis-delimited stream still yields the bytes decoded
883      // before the error, and the page dictionary sits at the front — so an
884      // error is only fatal when nothing at all came out.
885      continue;
886    }
887    out.push_str(&String::from_utf8_lossy(&buf));
888    out.push('\n');
889  }
890  (!out.is_empty()).then_some(out)
891}
892
893/// Parse `TOKEN [ llx lly urx ury ]` from PDF content, returning `(w, h)`.
894fn parse_pdf_box(content: &str, token: &str) -> Option<(f64, f64)> {
895  let start = content.find(token)? + token.len();
896  let rest = &content[start..];
897  let lb = rest.find('[')?;
898  let rb = rest[lb..].find(']')? + lb;
899  let mut it = rest[lb + 1..rb]
900    .split_whitespace()
901    .filter_map(|s| s.parse::<f64>().ok());
902  let (x0, y0, x1, y1) = (it.next()?, it.next()?, it.next()?, it.next()?);
903  Some(((x1 - x0).abs(), (y1 - y0).abs()))
904}
905
906/// Byte-level substring search — avoids a UTF-8 conversion for the fast-fail.
907fn byte_find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
908  if needle.is_empty() || needle.len() > haystack.len() {
909    return None;
910  }
911  haystack.windows(needle.len()).position(|w| w == needle)
912}
913
914/// Natural SVG size in pt, from the root `<svg>` element: the root
915/// `width`/`height` lengths (a unitless value is CSS px, exactly as a browser
916/// treats it), else the `viewBox` extent (user units ≈ CSS px) as a last
917/// resort. Gives at least a correct aspect ratio, which is all a `width=`-ed
918/// inclusion needs. `None` if the file isn't an SVG or has no usable geometry.
919///
920/// Width/height lead and the viewBox only backstops them — see
921/// [`read_svg_viewport_px`] for the full rationale (issue #696).
922fn read_svg_size_pt(path: &Path) -> Option<(f64, f64)> {
923  let head = read_head_lossy(path)?;
924  let tag = svg_root_tag(&head)?;
925  if let Some((w, h)) = svg_root_lengths_px(tag) {
926    return Some((px_to_pt(w), px_to_pt(h)));
927  }
928  let (vw, vh) = svg_viewbox_extent(tag)?;
929  // viewBox user units ≈ CSS px (1/96"); convert to pt for a plausible scale.
930  Some((px_to_pt(vw), px_to_pt(vh)))
931}
932
933/// SVG **viewport** size in CSS px, the way a browser takes it: the root
934/// `width`/`height` (a unitless value is CSS px), falling back to the `viewBox`
935/// only when the lengths are absent or relative (`%`). `None` when neither is
936/// usable, so the caller omits the dimensions and lets the browser size it.
937///
938/// Basis for `imagewidth`/`imageheight` in `LaTeXML::Post::Graphics`. The
939/// `viewBox` is only a coordinate system, not the rendered size; preferring it
940/// under-sized SVGs whose lengths disagreed with it (issue #696, reported by the
941/// LaTeXML maintainer). Not parity-relevant: Perl parses no SVG — it renders via
942/// Image::Magick, whose raster follows `width`/`height`, not the `viewBox`
943/// (`Util/Image.pm:86-97`); `pdftocairo`/`mutool` are our own beyond-Perl
944/// PDF→SVG pipeline, absent from Perl.
945pub fn read_svg_viewport_px(path: &Path) -> Option<(u32, u32)> {
946  let head = read_head_lossy(path)?;
947  let tag = svg_root_tag(&head)?;
948  let (w, h) = svg_root_lengths_px(tag).or_else(|| svg_viewbox_extent(tag))?;
949  Some((w.round().max(1.0) as u32, h.round().max(1.0) as u32))
950}
951
952/// The leading bytes of a file, decoded lossily. Bounded: an SVG can be
953/// hundreds of MB, and every geometry attribute we want lives in the root tag.
954/// Lossy rather than strict UTF-8 so a latin-1 preamble still yields a
955/// readable root tag (and so a multi-byte sequence split by the read boundary
956/// degrades to U+FFFD instead of failing the whole read).
957fn read_head_lossy(path: &Path) -> Option<String> {
958  use std::io::Read;
959  let mut file = std::fs::File::open(path).ok()?;
960  let mut buf = [0u8; 8192];
961  let n = file.read(&mut buf).ok()?;
962  Some(String::from_utf8_lossy(&buf[..n]).into_owned())
963}
964
965/// The root `<svg …>` start tag within `head`, quote-aware so a `>` inside an
966/// attribute value doesn't end the tag early. Skipping to `<svg` also steps over
967/// the `<?xml …?>` prolog, comments and any DOCTYPE — otherwise the prolog's
968/// `?>` would be mistaken for the end of the start tag.
969pub fn svg_root_tag(head: &str) -> Option<&str> {
970  let start = head.find("<svg")?;
971  let rest = &head[start..];
972  let mut quote: Option<char> = None;
973  for (i, c) in rest.char_indices() {
974    match quote {
975      Some(q) if c == q => quote = None,
976      Some(_) => {},
977      None if c == '"' || c == '\'' => quote = Some(c),
978      None if c == '>' => return Some(&rest[..i]),
979      None => {},
980    }
981  }
982  None
983}
984
985/// Value of the `name="…"` / `name='…'` attribute in an XML start tag.
986///
987/// The attribute **name is matched whole**: a bare substring search reads
988/// `stroke-width="2"` — legal on a root `<svg>` — as `width`, which is how a
989/// 634×805 drawing once measured 2×805.
990pub fn svg_attr_value<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
991  let mut from = 0;
992  while let Some(hit) = tag[from..].find(name) {
993    let at = from + hit;
994    from = at + name.len();
995    // Left boundary: the name must start an attribute, not end another one
996    // (`stroke-width`) — so what precedes it is whitespace, or the `<svg`.
997    let preceded_ok = tag[..at]
998      .chars()
999      .next_back()
1000      .is_some_and(|c| c.is_whitespace());
1001    if !preceded_ok {
1002      continue;
1003    }
1004    // Right boundary: `=` (optionally spaced) then a quoted value.
1005    let after = tag[from..].trim_start();
1006    let Some(after) = after.strip_prefix('=') else {
1007      continue;
1008    };
1009    let after = after.trim_start();
1010    let Some(q) = after.chars().next() else {
1011      continue;
1012    };
1013    if q != '"' && q != '\'' {
1014      continue;
1015    }
1016    let body = &after[q.len_utf8()..];
1017    let end = body.find(q)?;
1018    return Some(&body[..end]);
1019  }
1020  None
1021}
1022
1023/// `(width, height)` extent of the root `viewBox`, in user units (≈ CSS px).
1024/// Per the SVG grammar the four numbers are comma-**and/or**-whitespace
1025/// separated, so `viewBox="0,0,634,805"` must parse like `"0 0 634 805"`.
1026fn svg_viewbox_extent(tag: &str) -> Option<(f64, f64)> {
1027  let vb = svg_attr_value(tag, "viewBox")?;
1028  let mut it = vb
1029    .split(|c: char| c.is_whitespace() || c == ',')
1030    .filter(|s| !s.is_empty());
1031  let (_x, _y) = (it.next()?, it.next()?);
1032  let vw = it.next()?.parse::<f64>().ok()?;
1033  let vh = it.next()?.parse::<f64>().ok()?;
1034  Some((vw, vh))
1035}
1036
1037/// An SVG length attribute in CSS px. A unitless value is user units, i.e. px.
1038/// `None` for anything that isn't an absolute length (`%`, `em`, `ex`, …) —
1039/// those are resolved against a viewport we don't have, so the caller must fall
1040/// back to the viewBox rather than treat the bare number as pixels.
1041pub fn svg_attr_len_px(tag: &str, name: &str) -> Option<f64> {
1042  svg_len_px(svg_attr_value(tag, name)?)
1043}
1044
1045/// The root `<svg>` `width`/`height` as a CSS-px pair — the browser's sizing
1046/// basis. `Some` only when **both** are absolute lengths (a unitless value is
1047/// user units = px, a unit-bearing value is converted); `None` if either is
1048/// missing or relative (`%`, `em`, …), so the caller falls back to the viewBox.
1049fn svg_root_lengths_px(tag: &str) -> Option<(f64, f64)> {
1050  Some((
1051    svg_attr_len_px(tag, "width")?,
1052    svg_attr_len_px(tag, "height")?,
1053  ))
1054}
1055
1056/// Parse an SVG/CSS length into CSS px (1/96"), or `None` if it carries no
1057/// absolute unit. Unitless = user units = px.
1058fn svg_len_px(raw: &str) -> Option<f64> {
1059  let raw = raw.trim();
1060  // Split the number from its unit — but `6.34e2` must not split at the
1061  // exponent's `e`, which would silently read 634 as 6.
1062  let mut split = raw.len();
1063  for (i, c) in raw.char_indices() {
1064    if (c.is_alphabetic() || c == '%') && !is_exponent(&raw[i..]) {
1065      split = i;
1066      break;
1067    }
1068  }
1069  let (num, unit) = raw.split_at(split);
1070  let v = num.trim().parse::<f64>().ok()?;
1071  match unit.trim() {
1072    "" | "px" => Some(v),
1073    "pt" => Some(v * 96.0 / 72.0),
1074    "in" => Some(v * 96.0),
1075    "cm" => Some(v * 96.0 / 2.54),
1076    "mm" => Some(v * 96.0 / 25.4),
1077    "pc" => Some(v * 16.0),
1078    "Q" => Some(v * 96.0 / 101.6),
1079    _ => None, // %, em, ex, rem, vw, … → no absolute length
1080  }
1081}
1082
1083/// Does this trailing fragment start an exponent (`e-3`, `E+10`) rather than a
1084/// unit?
1085fn is_exponent(tail: &str) -> bool {
1086  let mut cs = tail.chars();
1087  matches!(cs.next(), Some('e') | Some('E'))
1088    && cs
1089      .next()
1090      .is_some_and(|c| c.is_ascii_digit() || c == '+' || c == '-')
1091}
1092
1093/// CSS px (1/96") → TeX pt (1/72.27").
1094fn px_to_pt(px: f64) -> f64 { px * 72.27 / 96.0 }
1095
1096#[cfg(test)]
1097mod svg_geometry_tests {
1098  use super::*;
1099
1100  /// Write `content` to a uniquely-named temp `.svg` and hand back the path.
1101  fn svg_file(name: &str, content: &str) -> PathBuf {
1102    let path = std::env::temp_dir().join(format!("lximg-{}-{name}.svg", std::process::id()));
1103    std::fs::write(&path, content).expect("write svg fixture");
1104    path
1105  }
1106
1107  #[test]
1108  fn root_tag_skips_the_prolog_and_stops_at_the_real_tag_end() {
1109    let head = "<?xml version=\"1.0\"?>\n<!-- a > in a comment -->\n<svg width=\"3\">\n<rect/>";
1110    assert_eq!(svg_root_tag(head), Some("<svg width=\"3\""));
1111    // A `>` inside an attribute value must not end the start tag.
1112    let quoted = r#"<svg desc="a > b" width="3"><rect/>"#;
1113    assert_eq!(svg_root_tag(quoted), Some(r#"<svg desc="a > b" width="3""#));
1114    assert_eq!(svg_root_tag("no svg here"), None);
1115  }
1116
1117  /// A bare substring search reads `stroke-width` as `width`. Both attribute
1118  /// orders, since the bug only bites when the decoy comes first.
1119  #[test]
1120  fn attr_value_matches_whole_names_not_substrings() {
1121    let decoy_first = r#"<svg stroke-width="2" width="634" height="805""#;
1122    assert_eq!(svg_attr_value(decoy_first, "width"), Some("634"));
1123    assert_eq!(svg_attr_value(decoy_first, "stroke-width"), Some("2"));
1124    let decoy_last = r#"<svg width="634" stroke-width="2""#;
1125    assert_eq!(svg_attr_value(decoy_last, "width"), Some("634"));
1126    // A name that appears only as a suffix of another attribute is absent.
1127    assert_eq!(svg_attr_value(r#"<svg stroke-width="2""#, "width"), None);
1128  }
1129
1130  #[test]
1131  fn attr_value_reads_both_quote_styles() {
1132    let single = r#"<svg xmlns='http://www.w3.org/2000/svg' width='634' height='805'"#;
1133    assert_eq!(svg_attr_value(single, "width"), Some("634"));
1134    assert_eq!(svg_attr_value(single, "height"), Some("805"));
1135    // Spaces around `=` are legal XML.
1136    assert_eq!(
1137      svg_attr_value(r#"<svg width = "634""#, "width"),
1138      Some("634")
1139    );
1140  }
1141
1142  /// The unit table, in CSS px (1in = 96px). Every absolute unit SVG allows,
1143  /// plus the three shapes that must NOT be read as a pixel count.
1144  #[test]
1145  fn len_px_converts_absolute_units_and_rejects_relative_ones() {
1146    let cases: &[(&str, Option<f64>)] = &[
1147      ("634", Some(634.0)), // unitless = user units = px
1148      ("634px", Some(634.0)),
1149      ("10cm", Some(377.952_755_905_511_8)),
1150      ("7.5cm", Some(283.464_566_929_133_84)),
1151      ("100mm", Some(377.952_755_905_511_8)),
1152      ("4in", Some(384.0)),
1153      ("72pt", Some(96.0)),
1154      ("6pc", Some(96.0)),
1155      ("6.34e2", Some(634.0)), // exponent, not a `e` unit
1156      ("-5", Some(-5.0)),
1157      ("100%", None), // resolved against a viewport we don't have
1158      ("2em", None),
1159      ("50vw", None),
1160      ("", None),
1161      ("wide", None),
1162    ];
1163    for (raw, want) in cases {
1164      match (svg_len_px(raw), want) {
1165        (Some(got), Some(w)) => assert!(
1166          (got - w).abs() < 1e-9,
1167          "svg_len_px({raw:?}) = {got}, want {w}"
1168        ),
1169        (got, want) => assert_eq!(
1170          got.is_none(),
1171          want.is_none(),
1172          "svg_len_px({raw:?}) = {got:?}"
1173        ),
1174      }
1175    }
1176  }
1177
1178  /// The viewport reader sizes the way a browser does: the root `width`/`height`
1179  /// lead, the `viewBox` only backstops them (issue #696). `pdftocairo -svg`
1180  /// writes `width="612pt"`, which a browser renders at 612·96/72 = 816 px — not
1181  /// the viewBox's 612. `mutool draw -F svg` writes a unitless `612`, i.e. 612
1182  /// px, matching its viewBox. Root tags copied verbatim from the tools.
1183  #[test]
1184  fn viewport_px_sizes_from_root_lengths_like_a_browser() {
1185    let pdftocairo = svg_file(
1186      "pdftocairo",
1187      r#"<?xml version="1.0" encoding="UTF-8"?>
1188<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="612pt" height="792pt" viewBox="0 0 612 792">
1189<defs/></svg>"#,
1190    );
1191    assert_eq!(read_svg_viewport_px(&pdftocairo), Some((816, 1056)));
1192    let mutool = svg_file(
1193      "mutool",
1194      r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" width="612" height="792" viewBox="0 0 612 792">
1195<defs/></svg>"#,
1196    );
1197    assert_eq!(read_svg_viewport_px(&mutool), Some((612, 792)));
1198    let _ = std::fs::remove_file(pdftocairo);
1199    let _ = std::fs::remove_file(mutool);
1200  }
1201
1202  /// The `viewBox` is only a fallback now: it sizes the viewport iff the root
1203  /// carries no absolute `width`/`height`. When both are present the lengths win
1204  /// (that is the whole of issue #696), so a viewBox that disagrees with them is
1205  /// ignored for sizing.
1206  #[test]
1207  fn viewport_px_uses_the_viewbox_only_when_lengths_are_absent() {
1208    let vb_only = svg_file(
1209      "vb_only",
1210      r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><rect/></svg>"#,
1211    );
1212    assert_eq!(read_svg_viewport_px(&vb_only), Some((640, 480)));
1213    // Lengths present and disagreeing with the viewBox → lengths win.
1214    let both = svg_file(
1215      "vb_both",
1216      r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 640 480"><rect/></svg>"#,
1217    );
1218    assert_eq!(read_svg_viewport_px(&both), Some((200, 100)));
1219    // A percentage width is not absolute → fall through to the viewBox.
1220    let pct_w = svg_file(
1221      "vb_pct",
1222      r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 640 480"><rect/></svg>"#,
1223    );
1224    assert_eq!(read_svg_viewport_px(&pct_w), Some((640, 480)));
1225    for p in [vb_only, both, pct_w] {
1226      let _ = std::fs::remove_file(p);
1227    }
1228  }
1229
1230  /// Without a viewBox the root lengths are the viewport — and they must be
1231  /// *converted*, not truncated. Reading `10cm` as 10 px is how a poster-sized
1232  /// drawing became a 10-pixel thumbnail (issue 498 follow-up).
1233  #[test]
1234  fn viewport_px_converts_unit_bearing_lengths_when_there_is_no_viewbox() {
1235    let cm = svg_file(
1236      "cm",
1237      r#"<svg xmlns="http://www.w3.org/2000/svg" width="10cm" height="7.5cm"><rect/></svg>"#,
1238    );
1239    assert_eq!(read_svg_viewport_px(&cm), Some((378, 283)));
1240    let inch = svg_file("in", r#"<svg width="4in" height="2in"><rect/></svg>"#);
1241    assert_eq!(read_svg_viewport_px(&inch), Some((384, 192)));
1242    let quoted = svg_file(
1243      "sq",
1244      r#"<svg xmlns='http://www.w3.org/2000/svg' width='634' height='805'><rect/></svg>"#,
1245    );
1246    assert_eq!(read_svg_viewport_px(&quoted), Some((634, 805)));
1247    let decoy = svg_file(
1248      "decoy",
1249      r#"<svg xmlns="http://www.w3.org/2000/svg" stroke-width="2" width="634" height="805"><rect/></svg>"#,
1250    );
1251    assert_eq!(read_svg_viewport_px(&decoy), Some((634, 805)));
1252    for p in [cm, inch, quoted, decoy] {
1253      let _ = std::fs::remove_file(p);
1254    }
1255  }
1256
1257  /// A percentage-sized root with no viewBox has no intrinsic pixel size at
1258  /// all. `None` is the whole point: the caller then emits no width/height and
1259  /// the browser sizes the image itself, which is strictly better than
1260  /// asserting `width="100"`.
1261  #[test]
1262  fn viewport_px_declines_relative_lengths_rather_than_inventing_pixels() {
1263    let pct = svg_file("pct", r#"<svg width="100%" height="100%"><rect/></svg>"#);
1264    assert_eq!(read_svg_viewport_px(&pct), None);
1265    let none = svg_file(
1266      "bare",
1267      r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#,
1268    );
1269    assert_eq!(read_svg_viewport_px(&none), None);
1270    for p in [pct, none] {
1271      let _ = std::fs::remove_file(p);
1272    }
1273  }
1274
1275  /// The SVG grammar allows comma-separated viewBox numbers.
1276  #[test]
1277  fn viewport_px_parses_a_comma_separated_viewbox() {
1278    let comma = svg_file("comma", r#"<svg viewBox="0,0,634,805"><rect/></svg>"#);
1279    assert_eq!(read_svg_viewport_px(&comma), Some((634, 805)));
1280    let _ = std::fs::remove_file(comma);
1281  }
1282
1283  /// `read_svg_size_pt` shares the viewport reader's precedence — root lengths
1284  /// first, viewBox second — differing only in that it answers "how big would
1285  /// this typeset (pt)?" rather than "how many px is the viewport?". SVG `pt` is
1286  /// a PostScript big point (1/72"), and a unitless length is CSS px.
1287  #[test]
1288  fn size_pt_prefers_absolute_lengths_then_falls_back_to_the_viewbox() {
1289    // 4in = 288.something TeX pt (72.27/in).
1290    let inch = svg_file(
1291      "pt_in",
1292      r#"<svg width="4in" height="2in" viewBox="0 0 10 5"><rect/></svg>"#,
1293    );
1294    let (w, h) = read_svg_size_pt(&inch).expect("absolute lengths");
1295    assert!((w - 4.0 * 72.27).abs() < 1e-9, "w = {w}");
1296    assert!((h - 2.0 * 72.27).abs() < 1e-9, "h = {h}");
1297    // SVG `pt` is a PostScript big point (1/72"), NOT a TeX pt (1/72.27") — so
1298    // `72pt` is one inch, i.e. 72.27 TeX pt. The old reader equated the two
1299    // units and under-reported every pt-sized SVG by 0.375%.
1300    let bigpt = svg_file("pt_pt", r#"<svg width="72pt" height="36pt"><rect/></svg>"#);
1301    let (w, h) = read_svg_size_pt(&bigpt).expect("pt lengths");
1302    assert!((w - 72.27).abs() < 1e-9, "w = {w}");
1303    assert!((h - 72.27 / 2.0).abs() < 1e-9, "h = {h}");
1304    let _ = std::fs::remove_file(bigpt);
1305    // Unitless lengths are CSS px, and they WIN over a disagreeing viewBox
1306    // (issue #696): 96 px → 72.27 pt, 48 px → 36.135 pt, not the viewBox's 634.
1307    let unitless = svg_file(
1308      "pt_len",
1309      r#"<svg width="96" height="48" viewBox="0 0 634 805"><rect/></svg>"#,
1310    );
1311    let (w, h) = read_svg_size_pt(&unitless).expect("root lengths");
1312    assert!((w - 72.27).abs() < 1e-9, "w = {w}");
1313    assert!((h - 72.27 / 2.0).abs() < 1e-9, "h = {h}");
1314    // Only a root without absolute lengths falls back to the viewBox.
1315    let vb_only = svg_file("pt_vb", r#"<svg viewBox="0 0 96 48"><rect/></svg>"#);
1316    let (w, h) = read_svg_size_pt(&vb_only).expect("viewBox fallback");
1317    assert!((w - 72.27).abs() < 1e-9, "w = {w}");
1318    assert!((h - 72.27 / 2.0).abs() < 1e-9, "h = {h}");
1319    for p in [inch, unitless, vb_only] {
1320      let _ = std::fs::remove_file(p);
1321    }
1322  }
1323}
1324
1325/// Characterization tests for the engine-side image sizing pipeline.
1326///
1327/// **These pin behaviour, not correctness.** Several of the numbers below are
1328/// known to disagree with pdflatex — an EPS BoundingBox is read as pixels, a
1329/// PNG is assumed to be 100 dpi, an SVG 96 dpi, and a box is quantized to whole
1330/// device pixels. They are recorded exactly as they are today so that the
1331/// planned unification of the sizing pipeline (one probe, one resolution
1332/// policy, one graphicx algebra) has to declare every change it makes instead
1333/// of drifting silently. When a value here changes, that is a decision, and the
1334/// comment above it says which way the current number leans.
1335///
1336/// Measured references, same 200x100 figure in each format, `\the\wd0` with no
1337/// graphicx options, recorded 2026-08-04:
1338///
1339/// | source              | pdflatex   | Perl LaTeXML | here       |
1340/// |---------------------|------------|--------------|------------|
1341/// | PNG 200x100 px      | 200.7495pt | 144.54pt     | 144.54pt   |
1342/// | EPS BBox 200x100 bp | -          | (no sizer)   | 144.54pt   |
1343/// | PDF 200x100 bp      | 200.7495pt | (no sizer)   | 200.75pt   |
1344/// | SVG viewBox 200x100 | -          | (no sizer)   | 150.5625pt |
1345#[cfg(test)]
1346mod sizing_characterization_tests {
1347  use super::*;
1348
1349  fn fixture(name: &str, bytes: &[u8]) -> PathBuf {
1350    // A per-call sequence makes every fixture path unique. Two tests reused the
1351    // same `name` ("m.pdf"), so keying only on pid+name let them race on one temp
1352    // file when run in parallel: whichever wrote last won, and the other test's
1353    // reader saw the wrong bytes -> a flaky `None` under full-suite load (it only
1354    // surfaced when scheduling made the two writes overlap).
1355    use std::sync::atomic::{AtomicU64, Ordering};
1356    static SEQ: AtomicU64 = AtomicU64::new(0);
1357    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1358    let path = std::env::temp_dir().join(format!("lxsize-{}-{seq}-{name}", std::process::id()));
1359    std::fs::write(&path, bytes).expect("write fixture");
1360    path
1361  }
1362
1363  fn io_err(kind: std::io::ErrorKind) -> std::io::Error { std::io::Error::from(kind) }
1364
1365  /// A genuine `NotFound` is a missing file, not a lock: it must map straight to
1366  /// `None` on the first attempt, with no retry and no sleep. The "no sleep"
1367  /// half is what keeps `read_pdf_page_box`/`read_image_dimensions` cheap for
1368  /// the common missing-figure case, so the perf argument depends on it.
1369  #[test]
1370  fn transient_retry_notfound_is_immediate_none() {
1371    let mut calls = 0u32;
1372    let got: Option<()> = with_transient_retry(|| {
1373      calls += 1;
1374      Err(io_err(std::io::ErrorKind::NotFound))
1375    });
1376    assert!(got.is_none(), "NotFound must map to None");
1377    assert_eq!(calls, 1, "NotFound must not be retried");
1378  }
1379
1380  /// A lock that clears after a couple of tries: the op is retried and its
1381  /// eventual `Ok` is returned — a fresh figure is not silently dropped to 0x0.
1382  #[test]
1383  fn transient_retry_recovers_after_transient_errors() {
1384    let mut calls = 0u32;
1385    let got = with_transient_retry(|| {
1386      calls += 1;
1387      if calls < 3 {
1388        Err(io_err(std::io::ErrorKind::PermissionDenied))
1389      } else {
1390        Ok(42u32)
1391      }
1392    });
1393    assert_eq!(
1394      got,
1395      Some(42),
1396      "a clearing lock should be retried then succeed"
1397    );
1398    assert_eq!(calls, 3, "should retry until the op succeeds");
1399  }
1400
1401  /// A persistent non-`NotFound` error gives up with `None` after the retry cap
1402  /// (1 initial attempt + 10 retries = 11 invocations) instead of looping.
1403  #[test]
1404  fn transient_retry_gives_up_after_cap() {
1405    let mut calls = 0u32;
1406    let got: Option<()> = with_transient_retry(|| {
1407      calls += 1;
1408      Err(io_err(std::io::ErrorKind::PermissionDenied))
1409    });
1410    assert!(got.is_none(), "a persistent error must give up with None");
1411    assert_eq!(calls, 11, "one attempt then retries up to the cap");
1412  }
1413
1414  /// A minimal `%PDF-1.5` whose only object is a Flate-compressed object stream
1415  /// carrying `payload` — the shape pdflatex emits for a page tree since 1.5.
1416  fn objstm_pdf(payload: &[u8]) -> Vec<u8> {
1417    use std::io::Write;
1418    let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
1419    enc.write_all(payload).expect("deflate");
1420    let body = enc.finish().expect("finish");
1421    let mut pdf = Vec::from(
1422      &b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode >>\nstream\n"[..],
1423    );
1424    pdf.extend_from_slice(&body);
1425    pdf.extend_from_slice(b"\nendstream\nendobj\n");
1426    pdf
1427  }
1428
1429  /// A PNG header with the given IHDR dimensions. `read_image_dimensions` reads
1430  /// a fixed 32-byte prefix and takes bytes 16..24 as width/height, so an
1431  /// honest signature + IHDR is the whole contract; no CRC is consulted.
1432  fn png_header(w: u32, h: u32) -> Vec<u8> {
1433    let mut v = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
1434    v.extend_from_slice(&13u32.to_be_bytes());
1435    v.extend_from_slice(b"IHDR");
1436    v.extend_from_slice(&w.to_be_bytes());
1437    v.extend_from_slice(&h.to_be_bytes());
1438    v.extend_from_slice(&[0x08, 0x02, 0x00, 0x00, 0x00]);
1439    v.extend_from_slice(&[0u8; 16]); // pad past the 32-byte read_exact
1440    v
1441  }
1442
1443  /// A JPEG with a single SOF0 frame header. The reader scans markers for
1444  /// 0xC0..=0xCF (minus DHT/JPG/DAC) and takes height then width, big-endian.
1445  fn jpeg_header(w: u16, h: u16) -> Vec<u8> {
1446    let mut v = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
1447    v.extend_from_slice(&h.to_be_bytes());
1448    v.extend_from_slice(&w.to_be_bytes());
1449    v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
1450    v.extend_from_slice(&[0xFF, 0xD9]);
1451    v.extend_from_slice(&[0u8; 16]);
1452    v
1453  }
1454
1455  // ── layer 1: what each format probe returns, and in which unit ────────
1456
1457  /// PNG and JPEG report true device pixels; EPS reports **bp** through the
1458  /// same `(u32, u32)` channel. Nothing in the type distinguishes them, which
1459  /// is the defect the unification is meant to remove — pinned here so the
1460  /// removal is visible.
1461  #[test]
1462  fn read_image_dimensions_returns_pixels_for_raster_and_bp_for_eps() {
1463    let png = fixture("dims.png", &png_header(200, 100));
1464    assert_eq!(read_image_dimensions(&png), Some((200, 100)), "PNG IHDR px");
1465
1466    let jpg = fixture("dims.jpg", &jpeg_header(640, 480));
1467    assert_eq!(read_image_dimensions(&jpg), Some((640, 480)), "JPEG SOF px");
1468
1469    // 200 x 100 **bp**, handed back as if it were 200 x 100 pixels.
1470    let eps = fixture(
1471      "dims.eps",
1472      b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n%%EndComments\n",
1473    );
1474    assert_eq!(
1475      read_image_dimensions(&eps),
1476      Some((200, 100)),
1477      "EPS bp-as-px"
1478    );
1479
1480    // HiResBoundingBox wins over BoundingBox when both are present.
1481    let hires = fixture(
1482      "hires.eps",
1483      b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n\
1484        %%HiResBoundingBox: 0 0 199.5 99.4\n%%EndComments\n",
1485    );
1486    assert_eq!(
1487      read_image_dimensions(&hires),
1488      Some((200, 99)),
1489      "HiRes wins, rounded"
1490    );
1491
1492    // Formats this reader does not know stay `None` — that is what routes a
1493    // PDF or an SVG to the `natural_size_pt` fallback.
1494    let pdf = fixture(
1495      "dims1.pdf",
1496      b"%PDF-1.4\n1 0 obj\n<< /MediaBox [0 0 200 100] >>\nendobj\n",
1497    );
1498    assert_eq!(
1499      read_image_dimensions(&pdf),
1500      None,
1501      "PDF is not this reader's job"
1502    );
1503  }
1504
1505  /// The PDF page box: CropBox is pdfTeX's default and wins over MediaBox, and
1506  /// a box compressed into an object stream is inflated and read.
1507  ///
1508  /// That last case is not hypothetical. Across 14 real PDFs in this repo, 5
1509  /// returned `None` before the object-stream pass, correlating exactly with
1510  /// `ObjStm` — the default for `%PDF-1.5` and later, which is what modern
1511  /// pdflatex emits — and those figures reached the engine with a 0x0 natural
1512  /// box. All 14 now match `pdfinfo` exactly.
1513  #[test]
1514  fn read_pdf_page_box_prefers_cropbox_and_reaches_into_object_streams() {
1515    let media = fixture("m.pdf", b"%PDF-1.4\n<< /MediaBox [0 0 200 100] >>\n");
1516    assert_eq!(read_pdf_page_box(&media), Some((200.0, 100.0)));
1517
1518    let both = fixture(
1519      "b.pdf",
1520      b"%PDF-1.4\n<< /MediaBox [0 0 612 792] /CropBox [0 0 200 100] >>\n",
1521    );
1522    assert_eq!(
1523      read_pdf_page_box(&both),
1524      Some((200.0, 100.0)),
1525      "CropBox wins"
1526    );
1527
1528    // Non-zero origin: the box is the extent, not the corner.
1529    let offset = fixture("o.pdf", b"%PDF-1.4\n<< /MediaBox [10 20 210 120] >>\n");
1530    assert_eq!(read_pdf_page_box(&offset), Some((200.0, 100.0)));
1531
1532    // A real object stream: the box exists only as deflated bytes.
1533    let objstm = fixture(
1534      "h.pdf",
1535      &objstm_pdf(b"5 0 << /Type /Page /MediaBox [0 0 200 100] >>"),
1536    );
1537    assert_eq!(read_pdf_page_box(&objstm), Some((200.0, 100.0)));
1538
1539    // A CropBox inside the stream still wins over a MediaBox beside it.
1540    let cropped = fixture(
1541      "hc.pdf",
1542      &objstm_pdf(b"5 0 << /MediaBox [0 0 612 792] /CropBox [0 0 200 100] >>"),
1543    );
1544    assert_eq!(read_pdf_page_box(&cropped), Some((200.0, 100.0)));
1545
1546    // Nothing readable anywhere: an object stream we cannot inflate.
1547    let opaque = fixture(
1548      "ho.pdf",
1549      b"%PDF-1.5\n<< /Type /ObjStm /N 12 /Filter /FlateDecode >>\nstream\nnot-zlib\nendstream\n",
1550    );
1551    assert_eq!(read_pdf_page_box(&opaque), None);
1552  }
1553
1554  /// `natural_size_pt` is the only place a file-read number is actually
1555  /// converted from its own unit into TeX pt — and it uses a different
1556  /// resolution per format: PDF at 72 (bp), SVG at 96 (CSS px).
1557  #[test]
1558  fn natural_size_pt_uses_72_for_pdf_and_96_for_svg() {
1559    let pdf = fixture("n.pdf", b"%PDF-1.4\n<< /MediaBox [0 0 200 100] >>\n");
1560    let (w, h) = natural_size_pt(&pdf).expect("pdf box");
1561    assert!((w - 200.0 * 72.27 / 72.0).abs() < 1e-9, "w = {w}"); // 200.75
1562    assert!((h - 100.0 * 72.27 / 72.0).abs() < 1e-9, "h = {h}");
1563
1564    let svg = fixture("n.svg", br#"<svg viewBox="0 0 200 100"><rect/></svg>"#);
1565    let (w, h) = natural_size_pt(&svg).expect("svg viewport");
1566    assert!((w - 200.0 * 72.27 / 96.0).abs() < 1e-9, "w = {w}"); // 150.5625
1567    assert!((h - 100.0 * 72.27 / 96.0).abs() < 1e-9, "h = {h}");
1568
1569    // A raster file has no page box and no SVG root: `None`, so the caller
1570    // keeps whatever the pixel reader gave it.
1571    let png = fixture("n.png", &png_header(200, 100));
1572    assert_eq!(natural_size_pt(&png), None);
1573  }
1574
1575  /// The compiled op *sequence* depends on where `angle` sits relative to the
1576  /// sizing keys — the ordering graphicx and pdflatex both honour. Pinned at the
1577  /// parse layer so the rule is guarded without a rasterizer: `angle` before a
1578  /// sizing key rotates first, after it rotates last, and a rotation with no
1579  /// sizing key at all rotates first.
1580  #[test]
1581  fn parse_orders_rotation_by_key_position() {
1582    use GraphicxOp::*;
1583    let w100 = ScaleTo {
1584      w:           Some(to_bp("100pt")),
1585      h:           None,
1586      keep_aspect: true,
1587    };
1588    assert_eq!(
1589      parse_graphicx_options("angle=90,width=100pt"),
1590      vec![Rotate(90.0), w100.clone()],
1591      "angle first -> rotate then scale"
1592    );
1593    assert_eq!(
1594      parse_graphicx_options("width=100pt,angle=90"),
1595      vec![w100, Rotate(90.0)],
1596      "width first -> scale then rotate"
1597    );
1598    assert_eq!(
1599      parse_graphicx_options("angle=90"),
1600      vec![Rotate(90.0)],
1601      "no sizing key -> rotate first (trivially)"
1602    );
1603    // scale is a sizing key too, so it flips the order the same way.
1604    assert_eq!(
1605      parse_graphicx_options("angle=90,scale=2")[0],
1606      Rotate(90.0),
1607      "angle before scale -> rotate first"
1608    );
1609    assert_eq!(
1610      parse_graphicx_options("scale=2,angle=90")[1],
1611      Rotate(90.0),
1612      "angle after scale -> rotate last"
1613    );
1614  }
1615
1616  // ── layer 3a: the pt-space algebra (fallback branch) ──────────────────
1617
1618  /// `graphicx_box_pt` works in pt throughout and never quantizes, so an
1619  /// explicit `width=100pt` comes out as exactly 100pt. Compare
1620  /// `sizer_quantizes_the_box_to_whole_device_pixels` below, which is the
1621  /// px-space algebra answering the *same* request with 99.7326pt.
1622  #[test]
1623  fn graphicx_box_pt_table() {
1624    let pt = |d: Dimension| d.value_of() as f64 / 65536.0;
1625    let case = |opts: &str| {
1626      let (w, h) = graphicx_box_pt(200.0, 100.0, opts);
1627      (pt(w), pt(h))
1628    };
1629    let near = |got: (f64, f64), want: (f64, f64), label: &str| {
1630      assert!(
1631        (got.0 - want.0).abs() < 1e-3 && (got.1 - want.1).abs() < 1e-3,
1632        "{label}: got {got:?}, want {want:?}"
1633      );
1634    };
1635    near(case(""), (200.0, 100.0), "no options = natural size");
1636    near(
1637      case("width=100pt"),
1638      (100.0, 50.0),
1639      "width= drives height by aspect",
1640    );
1641    near(
1642      case("height=25pt"),
1643      (50.0, 25.0),
1644      "height= drives width by aspect",
1645    );
1646    near(
1647      case("totalheight=25pt"),
1648      (50.0, 25.0),
1649      "totalheight aliases height",
1650    );
1651    near(case("scale=0.5"), (100.0, 50.0), "scale=");
1652    near(
1653      case("width=100pt,height=80pt"),
1654      (100.0, 80.0),
1655      "both, no keepaspect",
1656    );
1657    // keepaspectratio drops the more extreme request and fits inside the box.
1658    near(
1659      case("width=100pt,height=80pt,keepaspectratio"),
1660      (100.0, 50.0),
1661      "keepaspectratio fits width",
1662    );
1663    near(
1664      case("width=400pt,height=80pt,keepaspectratio"),
1665      (160.0, 80.0),
1666      "keepaspectratio fits height",
1667    );
1668    // An explicit width wins over scale (scale is only consulted when neither
1669    // width nor height is given).
1670    near(
1671      case("scale=2,width=100pt"),
1672      (100.0, 50.0),
1673      "width beats scale",
1674    );
1675    // Units other than pt parse through `Dimension::from_str`.
1676    near(case("width=1in"), (72.27, 36.135), "in parses");
1677    // A degenerate natural size carries no aspect ratio, and a lone `width=`
1678    // always wants one — Perl abandons the computation rather than guess
1679    // (`Util/Image.pm` L234), reporting nothing, i.e. a zero box.
1680    let (w, h) = graphicx_box_pt(0.0, 0.0, "width=100pt");
1681    near((pt(w), pt(h)), (0.0, 0.0), "zero natural height");
1682  }
1683
1684  // ── layer 3b: the px-space algebra, and the whole seam ────────────────
1685
1686  /// Drive the real entry point, `image_graphicx_sizer`, with an absolute
1687  /// candidate path so no `SOURCEDIRECTORY` is needed. Returns cached
1688  /// (width, height) in pt.
1689  fn sizer_pt(path: &Path, options: &str) -> (f64, f64) {
1690    let mut w = Whatsit::default();
1691    w.set_property("candidates", path.to_string_lossy().to_string());
1692    w.set_property("options", options.to_string());
1693    image_graphicx_sizer(&mut w);
1694    let get = |k: &str| match w.get_property(k).map(|c| c.into_owned()) {
1695      Some(Stored::Dimension(d)) => d.value_of() as f64 / 65536.0,
1696      other => panic!("{k} was {other:?}"),
1697    };
1698    (get("cached_width"), get("cached_height"))
1699  }
1700
1701  /// **The whole engine-side seam, as one matrix.** Same 200x100 figure in
1702  /// four containers, eight option strings, `cached_width`/`cached_height` in
1703  /// pt. This is the table the unified pipeline has to reproduce, row by row,
1704  /// or explicitly change.
1705  ///
1706  /// What each surprising row records:
1707  ///
1708  /// * **Four resolutions.** With no options the same figure is 144.54pt as a
1709  ///   PNG or EPS (100 dpi), 200.75pt as a PDF (72 dpi, i.e. bp), 150.5625pt as
1710  ///   an SVG (96 dpi, CSS px), and 0 as a PDF whose page box sits in an object
1711  ///   stream. pdflatex says 200.7495pt for the PNG and the PDF alike.
1712  /// * **Two algebras.** PNG/EPS take the px-space branch, PDF/SVG the pt-space
1713  ///   `graphicx_box_pt` fallback. They answer `width=100pt` differently:
1714  ///   99.7326 vs 100.0, because the px branch quantizes the box to a whole
1715  ///   device pixel (100pt -> 99.6265bp -> 137.848 px -> ceil 138 -> 99.7326pt).
1716  /// * **A single dimension always preserves aspect**, on both branches, as
1717  ///   Perl does by compiling `width=` alone into a scale-to with a 999999
1718  ///   sentinel and `keep_aspect` forced on (`Util/Image.pm` L188-189). Until
1719  ///   2026-08-04 the px branch left the height at its natural value; the
1720  ///   `keepaspectratio=true` that `graphicx_sty` injects had been hiding it
1721  ///   from ordinary LaTeX.
1722  /// * **`angle=` rotates the reserved box** (Perl L238-242). Until 2026-08-04
1723  ///   neither branch implemented the op, so a sideways figure reserved its
1724  ///   unrotated width — a Rust-only gap, since Perl has always rotated:
1725  ///   measured `angle=90` on the PNG gives Perl 72.27 x 144.54, and that is
1726  ///   now what this matrix pins.
1727  /// * **The last-resort branch** (unreadable page box) honours an explicit
1728  ///   `width=`/`height=` and reports 0 for the dimension not asked for.
1729  #[test]
1730  fn sizer_matrix_across_formats_and_options() {
1731    let png = fixture("m.png", &png_header(200, 100));
1732    let eps = fixture(
1733      "m.eps",
1734      b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n",
1735    );
1736    let pdf = fixture("m.pdf", b"%PDF-1.4\n<< /MediaBox [0 0 200 100] >>\n");
1737    let svg = fixture("m.svg", br#"<svg viewBox="0 0 200 100"><rect/></svg>"#);
1738    let objstm = fixture("m2.pdf", b"%PDF-1.5\n<< /Type /ObjStm >>\nstream\n..\n");
1739
1740    // (source, options, expected width pt, expected height pt)
1741    #[rustfmt::skip]
1742    let matrix: &[(&str, &str, f64, f64)] = &[
1743      // px-space branch: raster pixels, and an EPS BoundingBox read as pixels.
1744      ("png", "",                                 144.5400,  72.2700),
1745      ("png", "width=100pt,keepaspectratio=true",  99.7326,  49.8663),
1746      ("png", "width=100pt",                       99.7326,  49.8663),
1747      ("png", "scale=0.5",                         72.2700,  36.1350),
1748      ("png", "height=25pt,keepaspectratio=true",  49.8663,  25.2945),
1749      ("png", "width=100pt,height=80pt",           99.7326,  80.2197),
1750      ("png", "width=1in,keepaspectratio=true",    72.2700,  36.1350),
1751      ("png", "angle=90",                          72.2700, 144.5400),
1752      ("eps", "",                                 144.5400,  72.2700),
1753      ("eps", "width=100pt,keepaspectratio=true",  99.7326,  49.8663),
1754      ("eps", "width=100pt",                       99.7326,  49.8663),
1755      ("eps", "scale=0.5",                         72.2700,  36.1350),
1756      ("eps", "height=25pt,keepaspectratio=true",  49.8663,  25.2945),
1757      ("eps", "width=100pt,height=80pt",           99.7326,  80.2197),
1758      ("eps", "width=1in,keepaspectratio=true",    72.2700,  36.1350),
1759      ("eps", "angle=90",                          72.2700, 144.5400),
1760      // pt-space branch: the `natural_size_pt` fallback, no quantization.
1761      ("pdf", "",                                 200.7500, 100.3750),
1762      ("pdf", "width=100pt,keepaspectratio=true", 100.0000,  50.0000),
1763      ("pdf", "width=100pt",                      100.0000,  50.0000),
1764      ("pdf", "scale=0.5",                        100.3750,  50.1875),
1765      ("pdf", "height=25pt,keepaspectratio=true",  50.0000,  25.0000),
1766      ("pdf", "width=100pt,height=80pt",          100.0000,  80.0000),
1767      ("pdf", "width=1in,keepaspectratio=true",    72.2700,  36.1350),
1768      ("pdf", "angle=90",                         100.3750, 200.7500),
1769      ("svg", "",                                 150.5625,  75.2812),
1770      ("svg", "width=100pt,keepaspectratio=true", 100.0000,  50.0000),
1771      ("svg", "width=100pt",                      100.0000,  50.0000),
1772      ("svg", "scale=0.5",                         75.2812,  37.6406),
1773      ("svg", "height=25pt,keepaspectratio=true",  50.0000,  25.0000),
1774      ("svg", "width=100pt,height=80pt",          100.0000,  80.0000),
1775      ("svg", "width=1in,keepaspectratio=true",    72.2700,  36.1350),
1776      ("svg", "angle=90",                          75.2812, 150.5625),
1777      // last resort: nothing measurable, only an explicit request is honoured.
1778      ("objstm", "",                                0.0000,   0.0000),
1779      ("objstm", "width=100pt,keepaspectratio=true", 100.0000, 0.0000),
1780      ("objstm", "width=100pt",                    100.0000,   0.0000),
1781      ("objstm", "scale=0.5",                        0.0000,   0.0000),
1782      ("objstm", "height=25pt,keepaspectratio=true", 0.0000,  25.0000),
1783      ("objstm", "width=100pt,height=80pt",        100.0000,  80.0000),
1784      ("objstm", "width=1in,keepaspectratio=true",  72.2700,   0.0000),
1785      ("objstm", "angle=90",                         0.0000,   0.0000),
1786    ];
1787
1788    // Report EVERY divergence, not just the first: when this matrix moves it is
1789    // usually because a shared rule changed, and the whole delta is the useful
1790    // signal.
1791    let mut deltas = Vec::new();
1792    for (src, opts, want_w, want_h) in matrix {
1793      let path = match *src {
1794        "png" => &png,
1795        "eps" => &eps,
1796        "pdf" => &pdf,
1797        "svg" => &svg,
1798        _ => &objstm,
1799      };
1800      let (w, h) = sizer_pt(path, opts);
1801      // 1e-3 pt is ~1/70000 inch: far tighter than any behaviour change, loose
1802      // enough to survive `Dimension`'s fixed-point round trip.
1803      if (w - want_w).abs() >= 1e-3 || (h - want_h).abs() >= 1e-3 {
1804        deltas.push(format!(
1805          "  {src:<7} [{opts}]\n      pinned ({want_w:.4}, {want_h:.4})  got ({w:.4}, {h:.4})"
1806        ));
1807      }
1808    }
1809    assert!(
1810      deltas.is_empty(),
1811      "{} of {} pinned rows moved:\n{}",
1812      deltas.len(),
1813      matrix.len(),
1814      deltas.join("\n")
1815    );
1816  }
1817}