fix(measure): list a repeated measurement once and cap what a listing keeps

The listing read a measurement for every /Annots entry, so one annotation
referenced many times, or many small annotations sharing one indirect
2048-point vertex array, multiplied its points and text in memory. The
listing skips annotation references it has already read and stops,
marked truncated, once the kept points and text pass 32 MiB.
This commit is contained in:
aiongg committed 2026-10-22 17:08:54 +00:00
1 parent 8da1164f7d
commit a16661ac10
2 files changed
+47 -2

No files matched your search

+27 -2
View File
@@ -448,10 +448,20 @@ pub struct Unsupported {
pub struct Listing {
pub measurements: Vec<Measurement>,
pub unsupported: Vec<Unsupported>,
/// The annotation limit stopped enumeration early.
/// The annotation or retained-size limit stopped enumeration early.
pub truncated: bool,
}
const MAX_ANNOTATIONS: usize = 100_000;
/// Points and text one listing keeps, as counted by [`retained`]: about a thousand
/// measurements of [`MAX_POINTS`] points each.
const MAX_LISTED_BYTES: usize = 32 << 20;
/// The bytes a listed measurement keeps in points and text.
fn retained(m: &Measurement) -> usize {
let s = &m.scale;
[m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()]
.into_iter()
.fold(m.points.len().saturating_mul(std::mem::size_of::<Point>()), usize::saturating_add)
}
fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result<Measurement> {
let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?;
let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" };
@@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us
pub fn list(doc: &Document) -> Listing {
let mut out = Listing::default();
let mut count = 0usize;
let mut kept = 0usize;
let mut seen = std::collections::HashSet::new();
for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() {
let Some(o) = p.dict.get(b"Annots") else { continue };
let o = doc.resolve(o);
@@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing {
out.truncated = true;
return out;
}
// One annotation object under several entries or pages is one measurement.
if let Some(r) = a.as_ref()
&& !seen.insert(r)
{
continue;
}
let o = doc.resolve(a);
let Some(d) = o.as_dict() else { continue };
let kind = match d.name(b"IT") {
@@ -490,7 +508,14 @@ pub fn list(doc: &Document) -> Listing {
_ => continue,
};
match read_measurement(doc, d, kind, page, index) {
Ok(m) => out.measurements.push(m),
Ok(m) => {
kept = kept.saturating_add(retained(&m));
if kept > MAX_LISTED_BYTES {
out.truncated = true;
return out;
}
out.measurements.push(m);
}
Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }),
}
}
+20
View File
@@ -403,3 +403,23 @@ fn a_scale_ratio_converts_its_page_side_to_points() {
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
}
}
#[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());
}