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