Files
pdfcraft/crates/measure/src/tests.rs
T

464 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use pdfcraft_cos::{SaveOptions, write_full};
use pdfcraft_testkit::Pdf;
use std::sync::Arc;
fn fixture(content: &str, attrs: &str, extra: &[&str]) -> Document {
fixture_with("4 0 R", content, attrs, extra)
}
fn fixture_with(contents: &str, content: &str, attrs: &str, extra: &[&str]) -> Document {
let pdf = Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
.obj(format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>"))
.stream("", content)
.objs(extra);
Document::open(Arc::new(pdf.build())).unwrap()
}
fn close(a: f64, b: f64) {
assert!((a - b).abs() < 1e-8, "{a} != {b}");
}
fn new(kind: Kind, points: Vec<Point>) -> NewMeasurement {
NewMeasurement {
page: 0,
kind,
points,
scale: Scale::new(2.0, "m", 3).unwrap(),
style: Style { color: [0.0, 0.47, 0.84], ..Style::default() },
label: "Room 1".into(),
author: "A".into(),
}
}
#[test]
fn calibrated_lengths_concave_areas_and_axis_scales() {
let scale = Scale::calibrate([20.0, 30.0], [23.0, 34.0], 10.0, "m", 3).unwrap();
let distance = reading(Kind::Distance, &[[20.0, 30.0], [23.0, 34.0]], &scale).unwrap();
close(distance.value, 10.0);
close(distance.delta_x, 6.0);
close(distance.delta_y, 8.0);
assert_eq!(distance.label, "10.000 m");
let path = [[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0]];
close(reading(Kind::Perimeter, &path, &scale).unwrap().value, 24.0);
let concave = [[1e8, 1e8], [1e8 + 3.0, 1e8], [1e8 + 3.0, 1e8 + 1.0], [1e8 + 1.0, 1e8 + 1.0], [1e8 + 1.0, 1e8 + 3.0], [1e8, 1e8 + 3.0]];
close(reading(Kind::Area, &concave, &scale).unwrap().value, 20.0);
let reversed: Vec<_> = concave.into_iter().rev().collect();
close(reading(Kind::Area, &reversed, &scale).unwrap().value, 20.0);
let anisotropic = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() };
close(reading(Kind::Distance, &[[0.0, 0.0], [3.0, 4.0]], &anisotropic).unwrap().value, 180f64.sqrt());
close(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]], &anisotropic).unwrap().value, 36.0);
}
#[test]
fn viewport_precedence_first_point_user_unit_and_roundtrip() {
let mut d = fixture("", "/UserUnit 2 /Rotate 90 /CropBox [10 20 210 320]", &[]);
close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, 2.0 / 72.0);
set_scale(&mut d, 0, [10.0, 20.0, 210.0, 320.0], "large", &Scale::new(1.0, "m", 2).unwrap()).unwrap();
set_scale(&mut d, 0, [20.0, 30.0, 50.0, 60.0], "detail", &Scale::new(10.0, "m", 4).unwrap()).unwrap();
close(scale_at(&d, 0, [25.0, 35.0]).unwrap().x, 10.0);
close(scale_at(&d, 0, [100.0, 100.0]).unwrap().x, 1.0);
// An annotation carries the chosen scale even when its second point leaves the viewport.
let mut m = new(Kind::Distance, vec![[25.0, 35.0], [100.0, 35.0]]);
m.scale = scale_at(&d, 0, m.points[0]).unwrap();
add(&mut d, &m, &Meta { date: None, id: "measure".into() }).unwrap();
let bytes = write_full(&d, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(bytes)).unwrap();
let measurements = list(&reopened).measurements;
close(measurements[0].reading.value, 750.0);
assert_eq!(measurements[0].id, "measure");
assert_eq!(scale_at(&reopened, 0, [25.0, 35.0]).unwrap().precision, 4);
}
#[test]
fn standard_annotations_captions_restyle_move_and_unknown_keys() {
let mut d = fixture("", "", &[]);
let meta = Meta { date: None, id: String::new() };
for m in [
new(Kind::Distance, vec![[0.0, 0.0], [3.0, 4.0]]),
new(Kind::Perimeter, vec![[20.0, 0.0], [23.0, 0.0], [23.0, 4.0]]),
new(Kind::Area, vec![[40.0, 0.0], [43.0, 0.0], [43.0, 4.0]]),
] {
add(&mut d, &m, &meta).unwrap();
}
assert_eq!(list(&d).measurements.iter().map(|m| m.reading.value).collect::<Vec<_>>(), vec![10.0, 14.0, 24.0]);
let (r, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..]));
d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap();
pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap();
let (_, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.int(b"VendorKey"), Some(42));
let ap = d.resolve(dict.get(b"AP").unwrap());
let normal = d.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let Object::Stream(stream) = normal.as_ref() else { panic!("appearance missing") };
let stream = String::from_utf8(stream.decoded().unwrap()).unwrap();
assert!(stream.contains("(10.000 m) Tj"), "{stream}");
pdfcraft_annot::move_annotation(&mut d, 0, 0, 50.0, 60.0, &meta).unwrap();
assert_eq!(list(&d).measurements[0].points, vec![[50.0, 60.0], [53.0, 64.0]]);
close(list(&d).measurements[0].reading.value, 10.0);
}
#[test]
fn rejects_invalid_geometry_and_unsupported_scales() {
let mut d = fixture("", "", &[]);
for points in [vec![[f64::NAN, 0.0], [2.0, 0.0]], vec![[0.0, 0.0]; 2], vec![[0.0, 0.0], [1e10, 0.0]]] {
assert!(add(&mut d, &new(Kind::Distance, points), &Meta::default()).is_err());
}
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [2.0, 2.0], [0.0, 2.0], [2.0, 0.0]]), &Meta::default()).is_err());
assert!(Scale::new(f64::INFINITY, "m", 2).is_err());
assert!(Scale::new(1.0, "m", 7).is_err());
assert!(Scale::calibrate([0.0; 2], [0.0; 2], 1.0, "m", 2).is_err());
let mut dict = Scale::default().dictionary().unwrap();
dict.set(b"Subtype".to_vec(), Object::name("GEO"));
assert!(Scale::read(&d, &Object::Dict(dict)).unwrap_err().to_string().contains("rectilinear"));
assert!(set_scale(&mut d, 0, [0.0; 4], "bad", &Scale::default()).is_err());
}
#[test]
fn filled_open_triangle_snaps_to_closing_edge() {
for (op, closed) in [("f", true), ("F", true), ("f*", true), ("B", true), ("B*", true), ("h f", true), ("S", false)] {
let d = fixture(&format!("40 50 m 160 50 l 100 170 l {op}"), "", &[]);
let paths = snap::geometry(&d, 0).unwrap();
assert_eq!(paths.segments.len(), if closed { 3 } else { 2 }, "{op}");
let hit = paths.snap([70.0, 110.0], 5.0, snap::SnapOptions::default()).unwrap();
assert_eq!(hit.is_some(), closed, "{op}");
}
}
#[test]
fn snap_paths_endpoints_midpoints_and_intersections() {
let d = fixture("10 20 m 110 20 l S 60 0 m 60 80 l S 10 100 100 40 re S", "", &[]);
let paths = snap::geometry(&d, 0).unwrap();
assert_eq!(paths.segments.len(), 6);
let opts = snap::SnapOptions::default();
let hit = paths.snap([11.0, 20.0], 3.0, opts).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Endpoint);
assert_eq!(hit.point, [10.0, 20.0]);
let hit = paths.snap([60.0, 41.0], 3.0, opts).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Midpoint);
assert_eq!(hit.point, [60.0, 40.0]);
let hit = paths.snap([59.0, 21.0], 3.0, snap::SnapOptions { midpoints: false, ..opts }).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Intersection);
assert_eq!(hit.point, [60.0, 20.0]);
let hit = paths.snap([32.0, 22.0], 3.0, opts).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Path);
assert_eq!(hit.point, [32.0, 20.0]);
assert!(paths.snap([200.0, 200.0], 3.0, opts).unwrap().is_none());
assert!(paths.snap([f64::NAN, 0.0], 3.0, opts).is_err());
}
#[test]
fn filled_open_subpath_offers_the_same_snap_edges_as_explicit_close() {
let open = fixture("40 50 m 160 50 l 100 170 l f", "", &[]);
let closed = fixture("40 50 m 160 50 l 100 170 l h f", "", &[]);
let stroke = fixture("40 50 m 160 50 l 100 170 l S", "", &[]);
let filled = snap::geometry(&open, 0).unwrap();
let explicit = snap::geometry(&closed, 0).unwrap();
let stroked = snap::geometry(&stroke, 0).unwrap();
assert_eq!(filled.segments, explicit.segments);
assert_eq!(filled.segments.len(), 3);
assert_eq!(stroked.segments.len(), 2);
let paths_only = snap::SnapOptions { endpoints: false, midpoints: false, intersections: false, paths: true };
let hit = filled.snap([55.0, 80.0], 5.0, paths_only).unwrap().unwrap();
assert_eq!(hit.kind, snap::SnapKind::Path);
assert_eq!(hit.point, [55.0, 80.0]);
assert!(stroked.snap([55.0, 80.0], 5.0, paths_only).unwrap().is_none());
}
#[test]
fn fills_close_all_open_subpaths_but_strokes_do_not() {
let triangles = "0 0 m 20 0 l 10 20 l 40 0 m 60 0 l 50 20 l";
for paint in ["f", "F", "f*", "B", "B*", "b", "b*"] {
let doc = fixture(&format!("{triangles} {paint}"), "", &[]);
let geometry = snap::geometry(&doc, 0).unwrap();
assert_eq!(geometry.segments.len(), 6, "{paint}");
assert!(geometry.segments.contains(&[[10.0, 20.0], [0.0, 0.0]]), "{paint}");
assert!(geometry.segments.contains(&[[50.0, 20.0], [40.0, 0.0]]), "{paint}");
}
let stroked = fixture(&format!("{triangles} S"), "", &[]);
assert_eq!(snap::geometry(&stroked, 0).unwrap().segments.len(), 4);
}
#[test]
fn nested_forms_transforms_curves_and_cycles_are_bounded() {
let body = "0 0 m 10 0 l S 0 0 m 0 10 10 10 10 0 c S /Nested Do";
let form = format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Matrix [2 0 0 3 0 0] /Resources << /XObject << /Nested 5 0 R >> >> /Length {} >>\nstream\n{body}\nendstream",
body.len()
);
let d = fixture("q 1 0 0 1 100 200 cm /Nested Do Q", "", &[&form]);
let geometry = snap::geometry(&d, 0).unwrap();
assert_eq!(geometry.segments[0], [[100.0, 200.0], [120.0, 200.0]]);
assert!(geometry.endpoints.contains(&[120.0, 200.0]));
assert!(geometry.midpoints.contains(&[110.0, 222.5]));
assert!(geometry.segments.len() > 4 && geometry.segments.len() < 200);
let d = fixture(&"0 0 m 1 1 l S ".repeat(20_001), "", &[]);
assert!(snap::geometry(&d, 0).unwrap().truncated);
}
#[test]
fn measurement_csv_quotes_newlines_and_blocks_formula_cells() {
let mut d = fixture("", "", &[]);
let mut m = new(Kind::Distance, vec![[0.0, 0.0], [3.0, 4.0]]);
m.label = "=SUM(1,2)\n\"room\"".into();
m.author = " @cmd".into();
add(&mut d, &m, &Meta::default()).unwrap();
let csv = csv(&list(&d).measurements);
assert!(csv.contains("\"'=SUM(1,2)\n\"\"room\"\"\""));
assert!(csv.contains("\"' @cmd\""));
assert!(csv.starts_with("Page,Index,Type,Value"));
}
#[test]
fn coordinates_roundtrip_for_crop_rotation_and_user_unit() {
for rotation in [0, 90, 180, 270] {
let d = fixture("", &format!("/UserUnit 2 /Rotate {rotation} /CropBox [10 20 210 320]"), &[]);
for view in [[0.0, 0.0], [37.125, 81.375], [123.0, 175.0]] {
let user = view_to_user(&d, 0, view).unwrap();
let actual = user_to_view(&d, 0, user).unwrap();
close(actual[0], view[0]);
close(actual[1], view[1]);
}
let a = view_to_user(&d, 0, [10.0, 20.0]).unwrap();
let b = view_to_user(&d, 0, [70.0, 100.0]).unwrap();
let scale = scale_at(&d, 0, a).unwrap();
close(reading(Kind::Distance, &[a, b], &scale).unwrap().value, 100.0 / 72.0);
}
}
#[test]
fn zero_decimal_scale_uses_standard_rounding_format() {
let d = fixture("", "", &[]);
let scale = Scale::new(1.0, "m", 0).unwrap();
let dict = scale.dictionary().unwrap();
let first = dict.get(b"D").unwrap().as_array().unwrap()[0].as_dict().unwrap();
assert_eq!(first.name(b"F"), Some(&b"R"[..]));
assert_eq!(Scale::read(&d, &Object::Dict(dict)).unwrap(), scale);
}
#[test]
fn imported_measurement_appearance_is_not_silently_replaced() {
let mut d = fixture("", "", &[]);
add(&mut d, &new(Kind::Distance, vec![[10.0, 20.0], [40.0, 60.0]]), &Meta::default()).unwrap();
let (r, dict) = annot(&d, 0, 0).unwrap();
let original = dict.get(b"AP").unwrap().clone();
d.update_dict(r, |d| {
d.remove(b"PCMeasureValue");
})
.unwrap();
assert!(pdfcraft_annot::set_appearance(&mut d, r).is_err());
assert_eq!(annot(&d, 0, 0).unwrap().1.get(b"AP"), Some(&original));
close(list(&d).measurements[0].reading.value, 100.0);
}
#[test]
fn degenerate_and_dense_paths_keep_snap_targets_bounded() {
// Rejected (zero-length) segments record no endpoints or midpoints at all.
let d = fixture(&"0 0 0 0 re f ".repeat(100_000), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.segments.is_empty() && g.endpoints.is_empty() && g.midpoints.is_empty(), "{} {}", g.endpoints.len(), g.midpoints.len());
// Every paint drains into the page geometry; the page-wide target cap still holds.
let d = fixture(&"0 0 1 1 re f ".repeat(30_000), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated);
assert!(g.endpoints.len() + g.midpoints.len() <= 40_000 + 12, "{}", g.endpoints.len() + g.midpoints.len());
assert!(g.segments.len() <= 20_000 + 4);
// Unpainted curves and lines are capped too.
let d = fixture(&"0 0 m 5 9 1 9 7 0 c 0 3 l ".repeat(30_000), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated && g.endpoints.is_empty());
}
#[test]
fn deep_graphics_state_and_unreadable_streams_are_skipped_not_fatal() {
let d = fixture(&format!("0 0 m 10 0 l S {}", "q ".repeat(300)), "", &[]);
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated);
assert_eq!(g.segments.len(), 1);
// An unsupported filter cannot be decoded at all (corrupt Flate is decoded tolerantly).
let broken = "<< /Length 10 /Filter /NoSuchDecode >>\nstream\nnot-zlib!!\nendstream";
let d = fixture_with("[4 0 R 5 0 R]", "0 0 m 10 0 l S", "", &[broken]);
let g = snap::geometry(&d, 0).unwrap();
assert_eq!(g.unreadable, 1);
assert_eq!(g.segments.len(), 1);
let d = fixture_with("5 0 R", "", "", &[broken]);
assert_eq!(snap::geometry(&d, 0).unwrap().unreadable, 1);
}
#[test]
fn unsupported_measurements_are_reported_not_fatal() {
let mut d = fixture("", "", &[]);
add(&mut d, &new(Kind::Distance, vec![[0.0, 0.0], [3.0, 4.0]]), &Meta::default()).unwrap();
add(&mut d, &new(Kind::Distance, vec![[10.0, 0.0], [13.0, 4.0]]), &Meta::default()).unwrap();
// An imported compound feet-and-inches format (two NumberFormat dictionaries).
let (r, _) = annot(&d, 0, 1).unwrap();
let format = |unit: &str, c: f64| {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("NumberFormat"));
f.set(b"U".to_vec(), PdfString::text(unit));
f.set(b"C".to_vec(), Object::Real(c));
Object::Dict(f)
};
d.update_dict(r, |a| {
let mut m = Scale::default().dictionary().unwrap();
m.set(b"D".to_vec(), Object::Array(vec![format("ft", 1.0 / 864.0), format("in", 12.0)]));
a.set(b"Measure".to_vec(), Object::Dict(m));
})
.unwrap();
let listing = list(&d);
assert_eq!(listing.measurements.len(), 1);
assert_eq!(listing.unsupported.len(), 1);
assert_eq!((listing.unsupported[0].page, listing.unsupported[0].index), (0, 1));
assert!(listing.unsupported[0].reason.contains("compound"), "{}", listing.unsupported[0].reason);
// Invalid geometry on an imported annotation is reported the same way.
let (r, _) = annot(&d, 0, 0).unwrap();
d.update_dict(r, |a| a.set(b"L".to_vec(), nums([0.0, 0.0, f64::NAN, 1.0]))).unwrap();
let listing = list(&d);
assert!(listing.measurements.is_empty());
assert_eq!(listing.unsupported.len(), 2);
}
#[test]
fn non_ascii_units_roundtrip_and_render_in_win_ansi() {
let mut d = fixture("", "", &[]);
let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]]);
m.scale = Scale { area_unit: "m²".into(), ..Scale::new(2.0, "m", 1).unwrap() };
add(&mut d, &m, &Meta::default()).unwrap();
let bytes = write_full(&d, &SaveOptions::default()).unwrap();
let d = Document::open(Arc::new(bytes)).unwrap();
let listing = list(&d);
assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported);
assert_eq!(listing.measurements[0].scale.area_unit, "m²");
assert_eq!(listing.measurements[0].reading.label, "24.0 m²");
let (_, dict) = annot(&d, 0, 0).unwrap();
let ap = d.resolve(dict.get(b"AP").unwrap());
let normal = d.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let Object::Stream(stream) = normal.as_ref() else { panic!("appearance missing") };
let data = stream.decoded().unwrap();
// "²" is WinAnsi 0xB2, written as a raw byte rather than a UTF-8 replacement character.
assert!(data.windows(5).any(|w| w == b"0 m\xb2)"), "{}", String::from_utf8_lossy(&data));
assert!(!data.windows(3).any(|w| w == "\u{fffd}".as_bytes()));
assert!(Scale::new(1.0, "m\n", 2).is_err());
assert!(Scale::new(1.0, &"x".repeat(25), 2).is_err());
}
#[test]
fn closed_polygon_with_repeated_first_vertex_is_not_self_intersecting() {
let mut d = fixture("", "", &[]);
let points = vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]];
add(&mut d, &new(Kind::Area, points.clone()), &Meta::default()).unwrap();
let listing = list(&d);
assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported);
close(listing.measurements[0].reading.value, 48.0);
assert_eq!(listing.measurements[0].points, points);
// A closing duplicate doesn't turn too few vertices into a polygon.
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err());
}
#[test]
fn indirect_viewport_array_stays_indirect() {
let mut d = fixture("", "/VP 5 0 R", &["[]"]);
set_scale(&mut d, 0, [0.0, 0.0, 100.0, 100.0], "plan", &Scale::new(1.0, "m", 2).unwrap()).unwrap();
let page = crate::page(&d, 0).unwrap();
let r = page.dict.get(b"VP").and_then(Object::as_ref).expect("VP stays a reference");
assert_eq!(d.get(r).as_array().map(Vec::len), Some(1));
close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, 1.0);
}
#[test]
fn user_unit_is_read_as_acrobat_reads_it() {
// Values below 1 and values that aren't numbers count as 1, values over 75,000 as 75,000.
for (attrs, unit) in [("/UserUnit 0", 1.0), ("/UserUnit 0.5", 1.0), ("/UserUnit (2)", 1.0), ("/UserUnit 100000", 75_000.0)] {
let d = fixture("", attrs, &[]);
close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, unit / 72.0);
let user = view_to_user(&d, 0, [36.0, 72.0]).unwrap();
let view = user_to_view(&d, 0, user).unwrap();
close(view[0], 36.0);
close(view[1], 72.0);
}
}
#[test]
fn a_scale_ratio_converts_its_page_side_to_points() {
// #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm.
// The page side used to be points: 1 pt = 50 mm read the line as 1417 mm (50 × 72 / 25.4).
let mm = 72.0 / 25.4;
let line = [[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]];
let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 1.0, PageUnit::Mm, 50.0, "mm", 2).unwrap();
close(reading(Kind::Distance, &line, &scale).unwrap().value, 500.0);
assert_eq!(reading(Kind::Distance, &line, &scale).unwrap().label, "500.00 mm");
assert_eq!(scale.ratio, "1 mm = 50 mm");
// The PDF stores the factors per point (ISO 32000-2 §12.9): X converts user units to mm.
let dict = scale.dictionary().unwrap();
let x = dict.get(b"X").unwrap().as_array().unwrap()[0].as_dict().unwrap();
close(x.get(b"C").and_then(Object::as_f64).unwrap(), 50.0 / mm);
assert_eq!(Scale::read(&fixture("", "", &[]), &Object::Dict(dict)).unwrap(), scale);
// Every page unit converts: one of it on the page is one metre.
for (unit, points) in [(PageUnit::Pt, 1.0), (PageUnit::In, 72.0), (PageUnit::Mm, mm), (PageUnit::Cm, 10.0 * mm)] {
assert_eq!(PageUnit::from_label(unit.label()), Some(unit));
let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 2.0, unit, 2.0, "m", 3).unwrap();
close(reading(Kind::Distance, &[[0.0, 0.0], [0.0, 3.0 * points]], &scale).unwrap().value, 3.0);
assert_eq!(scale.ratio, format!("2 {} = 2 m", unit.label()));
}
assert_eq!(PageUnit::from_label("ft"), None);
// On a UserUnit 2 page one display point spans half a user unit; the ratio still holds on screen.
let mut d = fixture("", "/UserUnit 2", &[]);
let (a, b) = (view_to_user(&d, 0, [0.0, 0.0]).unwrap(), view_to_user(&d, 0, [1.0, 0.0]).unwrap());
let scale = Scale::ratio(a, b, 1.0, PageUnit::Cm, 0.5, "m", 2).unwrap();
let ends = [view_to_user(&d, 0, [10.0, 10.0]).unwrap(), view_to_user(&d, 0, [10.0 + 4.0 * 10.0 * mm, 10.0]).unwrap()];
close(reading(Kind::Distance, &ends, &scale).unwrap().value, 2.0);
// Saved and reopened, the viewport keeps the factors and the ratio as written.
set_scale(&mut d, 0, [0.0, 0.0, 612.0, 792.0], "plan", &scale).unwrap();
let d = Document::open(Arc::new(write_full(&d, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(scale_at(&d, 0, ends[0]).unwrap(), scale);
// Points per unit keep meaning points, and both sides must be finite and positive.
assert_eq!(Scale::calibrate([0.0, 0.0], [1.0, 0.0], 50.0, "mm", 2).unwrap().ratio, "1 pt = 50 mm");
for (page, real) in [(0.0, 1.0), (-1.0, 1.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0), (1.0, 0.0), (1.0, f64::NAN), (1e-300, 1e300)] {
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
}
}
/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct
/// dictionary) for every `Do`.
#[test]
fn xobject_resources_are_read_once_per_stream() {
let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]);
snap::XOBJECT_DICT_READS.with(|c| c.set(0));
let paths = snap::geometry(&d, 0).unwrap();
assert!(paths.segments.is_empty());
let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get);
assert!(reads <= 1, "{reads} reads of the page's XObject resources");
}
/// Finding the scale under a point reads only well-formed viewport boxes, and only as many
/// viewports as a page can be given, however many a file lists or however long their boxes.
#[test]
fn scale_lookup_reads_viewports_within_bounds() {
let vp = vec!["5 0 R"; 5_000].join(" ");
let long_box = vec!["0"; 2_000].join(" ");
let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]);
VIEWPORT_NUMBERS_READ.with(|c| c.set(0));
let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap();
assert_eq!(scale.unit, "in");
let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get);
assert!(read <= 4 * 1024, "{read} viewport numbers read");
}
#[test]
fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
let mut d = fixture("", "", &[]);
let points: Vec<Point> = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect();
add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap();
let (r, mut annotation) = annot(&d, 0, 0).unwrap();
let page = pdfcraft_model::pages(&d)[0].obj;
let set_annots = |d: &mut Document, list: Vec<Object>| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap();
// One annotation referenced 1000 times is one measurement.
set_annots(&mut d, vec![Object::Ref(r); 1000]);
assert_eq!(list(&d).measurements.len(), 1);
// Many small annotations sharing one 2048-point vertex array stop at the retained budget.
let vertices = d.add(annotation.get(b"Vertices").unwrap().clone());
annotation.set(b"Vertices".to_vec(), Object::Ref(vertices));
set_annots(&mut d, vec![Object::Dict(annotation); 1200]);
let listing = list(&d);
assert!(listing.truncated);
assert!(listing.measurements.iter().map(retained).sum::<usize>() <= MAX_LISTED_BYTES);
assert!(!listing.measurements.is_empty());
}
#[test]
fn a_stream_over_the_byte_budget_uses_it_up_once() {
// 50 references to one Flate stream that inflates past the 16 MiB budget decode once.
let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]);
let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect();
let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap();
let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50));
let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]);
let g = snap::geometry(&d, 0).unwrap();
assert_eq!(g.unreadable, 1);
assert!(g.truncated);
assert_eq!(g.segments.len(), 1, "the parts before it still snap");
}