mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
464 lines
24 KiB
Rust
464 lines
24 KiB
Rust
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");
|
||
}
|