fix(measure): charge snapping per filter stage and bound listing work (#5920)

This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-09 08:40:21 +08:00
1 parent ccbfa6d24a
commit eaeffa748b
3 files changed
+141 -19

No files matched your search

+44 -3
View File
@@ -452,6 +452,13 @@ pub struct Listing {
pub truncated: bool,
}
const MAX_ANNOTATIONS: usize = 100_000;
/// Vertex pairs one listing compares for self-intersection in all: an area's boundary check
/// is quadratic in its vertices, and many areas (or many annotations sharing one indirect vertex
/// array) must not multiply that into a stall.
const MAX_LISTED_WORK: usize = 50_000_000;
/// Scales already read by one listing, by the indirect `/Measure` object: one scale shared by
/// many annotations is read (and its ratio decoded) once, failures included.
type Scales = std::collections::HashMap<ObjRef, std::result::Result<Scale, String>>;
/// 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;
@@ -462,8 +469,18 @@ fn retained(m: &Measurement) -> usize {
.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"))?)?;
/// The scale a measurement annotation reads with, through the listing's cache: a scale given by
/// reference is read once, and its error kept, however many annotations name it.
fn scale_of(doc: &Document, d: &Dict, scales: &mut Scales) -> Result<Scale> {
let measure = d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?;
match measure.as_ref() {
Some(r) => scales.entry(r).or_insert_with(|| Scale::read(doc, measure).map_err(|e| e.to_string())).clone().map_err(|e| invalid(&e)),
None => Scale::read(doc, measure),
}
}
fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize, scales: &mut Scales) -> Result<Measurement> {
let scale = scale_of(doc, d, scales)?;
let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" };
let o = doc.resolve(d.get(key).ok_or_else(|| invalid("measurement has no vertices"))?);
let a = o.as_array().ok_or_else(|| invalid("invalid measurement vertices"))?;
@@ -482,7 +499,9 @@ pub fn list(doc: &Document) -> Listing {
let mut out = Listing::default();
let mut count = 0usize;
let mut kept = 0usize;
let mut work = 0usize;
let mut seen = std::collections::HashSet::new();
let mut scales = Scales::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);
@@ -507,7 +526,29 @@ pub fn list(doc: &Document) -> Listing {
Some(b"PolygonDimension") => Kind::Area,
_ => continue,
};
match read_measurement(doc, d, kind, page, index) {
// An area's boundary check compares every pair of edges: charge it before reading the
// vertices, so a listing stops at its work bound rather than at a stall. Other kinds
// read their vertices once.
let vertices = d.get(if kind == Kind::Distance { b"L".as_slice() } else { b"Vertices" }).map(|v| doc.resolve(v));
let n = vertices.as_deref().and_then(Object::as_array).map_or(0, |a| a.len() / 2).min(MAX_POINTS);
// An area's boundary check compares every pair of edges: charge it before reading the
// vertices, so a listing stops at its work bound rather than at a stall. The scale is
// resolved first, as `read_measurement` reads it: one that is rejected reads nothing at
// all, so it must not spend the work that later, valid measurements need.
if kind == Kind::Area {
if let Err(e) = scale_of(doc, d, &mut scales) {
out.unsupported.push(Unsupported { page, index, reason: e.to_string() });
continue;
}
work = work.saturating_add(n.saturating_mul(n));
} else {
work = work.saturating_add(n.max(1));
}
if work > MAX_LISTED_WORK {
out.truncated = true;
return out;
}
match read_measurement(doc, d, kind, page, index, &mut scales) {
Ok(m) => {
kept = kept.saturating_add(retained(&m));
if kept > MAX_LISTED_BYTES {
+23 -16
View File
@@ -1,7 +1,7 @@
//! Bounded path extraction and snapping in PDF user space, including nested Form XObjects.
use crate::{Point, Result, check_points, distance, invalid};
use pdfcraft_content::{Matrix, parse};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_content::{Matrix, ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object};
use serde::Serialize;
use std::collections::HashSet;
@@ -199,7 +199,9 @@ struct Walker<'a> {
doc: &'a Document,
geometry: Geometry,
seen: HashSet<ObjRef>,
bytes: usize,
/// Decoded bytes left for the page: every filter stage's output counts, so a stream that
/// expands and then shrinks pays for the expansion each time it is drawn.
budget: DecodeBudget,
streams: usize,
}
impl Walker<'_> {
@@ -215,46 +217,44 @@ impl Walker<'_> {
for part in arr {
let s = self.doc.resolve(part);
if let Object::Stream(s) = s.as_ref() {
let remaining = MAX_BYTES.saturating_sub(self.bytes).saturating_sub(joined.len());
let remaining = self.budget.remaining();
if remaining == 0 {
self.geometry.truncated = true;
break;
}
// An undecodable part is skipped; the parts before it still snap.
match s.decoded_within(remaining) {
match self.budget.decode_within(s, remaining) {
Ok(data) => {
joined.extend(data);
// A space, as the renderer joins pieces: a trailing comment runs on.
joined.push(b' ');
}
Err(_) => self.decode_failed(remaining),
Err(_) => self.decode_failed(),
}
}
}
return self.ops(&joined, resources, initial, depth);
}
let Object::Stream(stream) = o.as_ref() else { return Ok(()) };
let remaining = MAX_BYTES.saturating_sub(self.bytes);
let remaining = self.budget.remaining();
if remaining == 0 {
self.geometry.truncated = true;
return Ok(());
}
match stream.decoded_within(remaining) {
match self.budget.decode_within(stream, remaining) {
Ok(data) => self.ops(&data, resources, initial, depth),
Err(_) => {
self.decode_failed(remaining);
self.decode_failed();
Ok(())
}
}
}
/// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes,
/// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work.
fn decode_failed(&mut self, remaining: usize) {
/// A failed decode has paid the budget for what it decoded before failing (all of it, past
/// the bound): repeating a failing stream cannot repeat that work for free.
fn decode_failed(&mut self) {
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
self.bytes = self.bytes.saturating_add(remaining);
}
fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> {
self.bytes = self.bytes.saturating_add(data.len());
self.streams = self.streams.saturating_add(1);
let mut matrix = initial;
let mut stack = Vec::new();
@@ -268,7 +268,8 @@ impl Walker<'_> {
let (x, y) = m.apply(x, y);
[x, y]
};
for op in parse(data).ops {
// One operator at a time: a stream of millions of short operators is never held whole.
for op in ops(data) {
if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS
|| self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS
{
@@ -425,6 +426,11 @@ impl Walker<'_> {
self.seen.insert(r);
}
self.walk(o, res, m, depth + 1)?;
// A form that hit a bound ends this walk too: more `Do`s would
// only repeat the work of copying its resources.
if self.geometry.truncated {
break;
}
if let Some(r) = r {
self.seen.remove(&r);
}
@@ -449,7 +455,8 @@ pub fn geometry(doc: &Document, page_index: usize) -> Result<Geometry> {
let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o));
let empty = Dict::new();
let resources = resources.as_ref().and_then(|o| o.as_dict()).unwrap_or(&empty);
let mut walker = Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), bytes: 0, streams: 0 };
let mut walker =
Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), budget: DecodeBudget::new(MAX_BYTES, usize::MAX), streams: 0 };
// Extraction is lenient: limits and unreadable streams are reported in the geometry.
if let Some(contents) = p.dict.get(b"Contents") {
walker.walk(contents, resources, Matrix::IDENTITY, 0)?;
+74
View File
@@ -471,3 +471,77 @@ fn a_stream_over_the_byte_budget_uses_it_up_once() {
assert!(g.truncated);
assert_eq!(g.segments.len(), 1, "the parts before it still snap");
}
/// A content stream drawn again and again pays for every filter stage's output each time, not
/// only for what the last stage leaves: `/ASCIIHexDecode /RunLengthDecode` turns 4 MiB of hex
/// into 2 MiB and then 1 MiB, and eight draws of it are 24 MiB of decoding against a 16 MiB bound.
#[test]
fn repeated_content_pays_for_every_filter_stage() {
let hex = "0058".repeat(1 << 20);
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("<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R] >>")
.stream("/Filter [/ASCIIHexDecode /RunLengthDecode]", &hex);
let d = Document::open(Arc::new(pdf.build())).unwrap();
let g = snap::geometry(&d, 0).unwrap();
assert!(g.truncated, "eight draws decode past the page's bound");
}
/// One scale shared by many measurements is read once, and the self-intersection check of many
/// areas is charged against one work bound rather than run in full for each.
#[test]
fn listing_reads_a_shared_scale_once_and_bounds_boundary_checks() {
let measure = "<< /Type /Measure /Subtype /RL /R (1 pt = 1 pt) /X [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /D [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /A [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] >>";
// A convex 2,048-gon: every one of its edges is checked against every other.
let n = MAX_POINTS;
let verts: Vec<String> = (0..n)
.map(|i| {
let a = i as f64 / n as f64 * std::f64::consts::TAU;
format!("{:.3} {:.3}", 300.0 + 100.0 * a.cos(), 400.0 + 100.0 * a.sin())
})
.collect();
let verts = verts.join(" ");
let areas = 40;
let annots: Vec<String> = (0..areas).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut 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 4 0 R /Annots [{}] >>", annots.join(" ")))
.stream("", "")
.obj(measure);
for _ in 0..areas {
pdf = pdf.obj(format!("<< /Type /Annot /Subtype /Polygon /IT /PolygonDimension /Rect [0 0 612 792] /Vertices [{verts}] /Measure 5 0 R >>"));
}
let d = Document::open(Arc::new(pdf.build())).unwrap();
let l = list(&d);
// 40 areas of 2,048 vertices are 168 million pairs: the listing stops at its bound, with the
// areas before it listed.
assert!(l.truncated, "{} listed", l.measurements.len());
assert!((5..areas).contains(&l.measurements.len()), "{} listed", l.measurements.len());
assert!(l.unsupported.is_empty(), "{:?}", l.unsupported);
}
/// Perimeters and distances read their vertices once: only areas pay for the pairwise boundary
/// check, so many maximal perimeters list in full.
#[test]
fn perimeters_are_charged_linear_work() {
let measure = "<< /Type /Measure /Subtype /RL /R (1 pt = 1 pt) /X [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /D [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /A [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] >>";
let verts: Vec<String> = (0..MAX_POINTS).map(|i| format!("{} {}", 10 + i % 500, 10 + i / 500 * 3 + (i % 2))).collect();
let verts = verts.join(" ");
let lines = 40;
let annots: Vec<String> = (0..lines).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut 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 4 0 R /Annots [{}] >>", annots.join(" ")))
.stream("", "")
.obj(measure);
for _ in 0..lines {
pdf = pdf.obj(format!("<< /Type /Annot /Subtype /PolyLine /IT /PolyLineDimension /Rect [0 0 612 792] /Vertices [{verts}] /Measure 5 0 R >>"));
}
let d = Document::open(Arc::new(pdf.build())).unwrap();
let l = list(&d);
assert!(!l.truncated, "{} listed", l.measurements.len());
assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first());
}