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
21fn 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
46pub 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 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 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 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 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 let mut seen = rustc_hash::FxHashSet::default();
151 candidates.retain(|c| seen.insert(c.clone()));
152
153 candidates.join(",")
161}
162
163#[derive(Debug, Clone, PartialEq)]
169pub enum GraphicxOp {
170 Page(u32),
172 Trim {
174 l: f64,
175 b: f64,
176 r: f64,
177 t: f64,
178 },
179 Clip {
181 l: f64,
182 b: f64,
183 r: f64,
184 t: f64,
185 },
186 Rotate(f64),
188 Reflect,
189 Scale {
191 x: f64,
192 y: f64,
193 },
194 ScaleTo {
198 w: Option<f64>,
199 h: Option<f64>,
200 keep_aspect: bool,
201 },
202}
203
204pub 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 _ => 1.0,
231 };
232 v * factor
233}
234
235pub 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 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 (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
350pub 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 if w <= 0.0 || h <= 0.0 {
386 return (0.0, 0.0);
387 }
388 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 (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 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 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 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
448pub 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 }; 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 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 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 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 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 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 let width_pt = w * 72.27 / dpi;
560 let height_pt = h * 72.27 / dpi;
561
562 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
572pub 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 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 if header[0] == 0xFF && header[1] == 0xD8 {
594 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 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 if (header[0] == b'%' && (header[1] == b'!' || header[1] == b'%'))
620 || (header.starts_with(b"\xc5\xd0\xd3\xc6"))
621 {
623 let mut data = header.to_vec();
624 let mut extra = [0u8; 32768];
626 let n = file.read(&mut extra).ok().unwrap_or(0);
627 data.extend_from_slice(&extra[..n]);
628 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 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 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 return Some((w.round() as u32, h.round() as u32));
669 }
670 }
671 }
672
673 None
674}
675
676pub 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
686fn 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
698fn 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
710fn bp_to_pt(bp: f64) -> f64 { bp * 72.27 / 72.0 }
712
713pub 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 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
742pub 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
757fn pt_to_dim(pt: f64) -> Dimension { Dimension::new((pt * 65536.0).round() as i64) }
759
760fn graphicx_box_pt(nw: f64, nh: f64, options: &str) -> (Dimension, Dimension) {
765 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
779pub 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
808fn inflate_object_streams(bytes: &[u8]) -> Option<String> {
821 use std::io::Read;
822
823 const MAX_OBJSTM_SCAN: usize = 64;
825 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 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 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
873fn 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
886fn 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
894fn 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 Some((px_to_pt(vw), px_to_pt(vh)))
910}
911
912pub 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
939fn 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
952pub 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
972pub 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 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 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
1010fn 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
1024pub fn svg_attr_len_px(tag: &str, name: &str) -> Option<f64> {
1029 svg_len_px(svg_attr_value(tag, name)?)
1030}
1031
1032fn 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
1044fn svg_len_px(raw: &str) -> Option<f64> {
1047 let raw = raw.trim();
1048 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, }
1069}
1070
1071fn 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
1081fn px_to_pt(px: f64) -> f64 { px * 72.27 / 96.0 }
1083
1084#[cfg(test)]
1085mod svg_geometry_tests {
1086 use super::*;
1087
1088 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 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 #[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 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 assert_eq!(
1125 svg_attr_value(r#"<svg width = "634""#, "width"),
1126 Some("634")
1127 );
1128 }
1129
1130 #[test]
1133 fn len_px_converts_absolute_units_and_rejects_relative_ones() {
1134 let cases: &[(&str, Option<f64>)] = &[
1135 ("634", Some(634.0)), ("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)), ("-5", Some(-5.0)),
1145 ("100%", None), ("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 #[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 #[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("ed), 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 #[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 #[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 #[test]
1246 fn size_pt_prefers_absolute_lengths_then_falls_back_to_the_viewbox() {
1247 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 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 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#[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 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 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]); v
1334 }
1335
1336 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 #[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 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 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 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 #[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 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 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 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 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 #[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}"); 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}"); assert!((h - 100.0 * 72.27 / 96.0).abs() < 1e-9, "h = {h}");
1461
1462 let png = fixture("n.png", &png_header(200, 100));
1465 assert_eq!(natural_size_pt(&png), None);
1466 }
1467
1468 #[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 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 #[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 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 near(
1564 case("scale=2,width=100pt"),
1565 (100.0, 50.0),
1566 "width beats scale",
1567 );
1568 near(case("width=1in"), (72.27, 36.135), "in parses");
1570 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 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 #[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 #[rustfmt::skip]
1635 let matrix: &[(&str, &str, f64, f64)] = &[
1636 ("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 ("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 ("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 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 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}