1use 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
21pub 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 let dir = dir.trim().trim_matches('"');
57 let base = PathBuf::from(dir).join(path);
58 if has_extension {
59 if base.exists() {
60 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 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 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 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 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 let mut seen = rustc_hash::FxHashSet::default();
129 candidates.retain(|c| seen.insert(c.clone()));
130
131 candidates.join(",")
139}
140
141#[derive(Debug, Clone, PartialEq)]
147pub enum GraphicxOp {
148 Page(u32),
150 Trim {
152 l: f64,
153 b: f64,
154 r: f64,
155 t: f64,
156 },
157 Clip {
159 l: f64,
160 b: f64,
161 r: f64,
162 t: f64,
163 },
164 Rotate(f64),
166 Reflect,
167 Scale {
169 x: f64,
170 y: f64,
171 },
172 ScaleTo {
176 w: Option<f64>,
177 h: Option<f64>,
178 keep_aspect: bool,
179 },
180}
181
182pub 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 _ => 1.0,
209 };
210 v * factor
211}
212
213pub 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 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 (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
328pub 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 if w <= 0.0 || h <= 0.0 {
364 return (0.0, 0.0);
365 }
366 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 (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 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 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 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
426pub 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 }; 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 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 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 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 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 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 let width_pt = w * 72.27 / dpi;
538 let height_pt = h * 72.27 / dpi;
539
540 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
550fn 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
580fn read_file_resilient(path: &Path) -> Option<Vec<u8>> {
582 with_transient_retry(|| std::fs::read(path))
583}
584
585fn open_file_resilient(path: &Path) -> Option<std::fs::File> {
589 with_transient_retry(|| std::fs::File::open(path))
590}
591
592pub 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 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 if header[0] == 0xFF && header[1] == 0xD8 {
614 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 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 if (header[0] == b'%' && (header[1] == b'!' || header[1] == b'%'))
640 || (header.starts_with(b"\xc5\xd0\xd3\xc6"))
641 {
643 let mut data = header.to_vec();
644 let mut extra = [0u8; 32768];
646 let n = file.read(&mut extra).ok().unwrap_or(0);
647 data.extend_from_slice(&extra[..n]);
648 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 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 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 return Some((w.round() as u32, h.round() as u32));
689 }
690 }
691 }
692
693 None
694}
695
696pub 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
706fn 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
718fn 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
730fn bp_to_pt(bp: f64) -> f64 { bp * 72.27 / 72.0 }
732
733pub 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 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
762pub 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
777fn pt_to_dim(pt: f64) -> Dimension { Dimension::new((pt * 65536.0).round() as i64) }
779
780fn graphicx_box_pt(nw: f64, nh: f64, options: &str) -> (Dimension, Dimension) {
785 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
799pub 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
828fn inflate_object_streams(bytes: &[u8]) -> Option<String> {
841 use std::io::Read;
842
843 const MAX_OBJSTM_SCAN: usize = 64;
845 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 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 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
893fn 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
906fn 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
914fn 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 Some((px_to_pt(vw), px_to_pt(vh)))
931}
932
933pub 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
952fn 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
965pub 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
985pub 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 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 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
1023fn 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
1037pub fn svg_attr_len_px(tag: &str, name: &str) -> Option<f64> {
1042 svg_len_px(svg_attr_value(tag, name)?)
1043}
1044
1045fn 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
1056fn svg_len_px(raw: &str) -> Option<f64> {
1059 let raw = raw.trim();
1060 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, }
1081}
1082
1083fn 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
1093fn px_to_pt(px: f64) -> f64 { px * 72.27 / 96.0 }
1095
1096#[cfg(test)]
1097mod svg_geometry_tests {
1098 use super::*;
1099
1100 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 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 #[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 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 assert_eq!(
1137 svg_attr_value(r#"<svg width = "634""#, "width"),
1138 Some("634")
1139 );
1140 }
1141
1142 #[test]
1145 fn len_px_converts_absolute_units_and_rejects_relative_ones() {
1146 let cases: &[(&str, Option<f64>)] = &[
1147 ("634", Some(634.0)), ("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)), ("-5", Some(-5.0)),
1157 ("100%", None), ("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 #[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 #[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 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 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 #[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("ed), 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 #[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 #[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 #[test]
1288 fn size_pt_prefers_absolute_lengths_then_falls_back_to_the_viewbox() {
1289 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 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 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 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#[cfg(test)]
1346mod sizing_characterization_tests {
1347 use super::*;
1348
1349 fn fixture(name: &str, bytes: &[u8]) -> PathBuf {
1350 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 #[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 #[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 #[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 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 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]); v
1441 }
1442
1443 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 #[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 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 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 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 #[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 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 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 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 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 #[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}"); 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}"); assert!((h - 100.0 * 72.27 / 96.0).abs() < 1e-9, "h = {h}");
1568
1569 let png = fixture("n.png", &png_header(200, 100));
1572 assert_eq!(natural_size_pt(&png), None);
1573 }
1574
1575 #[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 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 #[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 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 near(
1671 case("scale=2,width=100pt"),
1672 (100.0, 50.0),
1673 "width beats scale",
1674 );
1675 near(case("width=1in"), (72.27, 36.135), "in parses");
1677 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 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 #[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 #[rustfmt::skip]
1742 let matrix: &[(&str, &str, f64, f64)] = &[
1743 ("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 ("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 ("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 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 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}