plate-test: bound text sampling, image size, resource depth and plate bytes (#544)

* plate-test: bound text sampling, image size, resource depth and plate bytes

- judge: sample text regions over integer pixel ranges clipped to the plate,
  so a huge or NaN region cannot stall the float counter.
- reference: the writer enforces the scene validator's image pixel cap
  (MAX_IMAGE_PX, shared with scene.rs) before generating pixels.
- structure: Walk::content follows nested Form/pattern/soft-mask resources
  at most MAX_RESOURCE_DEPTH (16) levels deep.
- tiff: a plate may hold at most MAX_PLATE_PIXELS (1 GiB) and its buffer is
  reserved fallibly.

* address review: report resource-depth truncation as an error; re-expand shared streams reached shallower

* address review: list shared groups and graphics states once; accept empty terminal Resources
This commit is contained in:
Dylan M. Taylor
2026-11-11 06:26:53 -05:00
committed by GitHub
parent 3dcaa07a81
commit 6154d957f8
5 changed files with 139 additions and 22 deletions
+16 -11
View File
@@ -153,19 +153,16 @@ fn point_pixels(point: &SamplePoint, plates: &BTreeMap<String, Plate>, res: f32)
(PointKind::Text, Some([x, y, w, h])) => {
let glyph = point.plate.as_deref().unwrap_or(TEXT_PLATE);
let plate = plates.get(glyph).ok_or_else(|| format!("no {glyph} plate to find the text on"))?;
let (x0, y0, x1, y1) = (px(x).max(0.0), px(y).max(0.0), px(x + w), px(y + h));
// Integer pixel ranges clipped to the plate: a huge or NaN region (`NaN.max(0.0)` is 0,
// `as usize` saturates) would otherwise stall a float counter or loop for ages.
let clip = |v: f32, max: usize| px(v).max(0.0).min(max as f32) as usize;
let (x0, x1) = (clip(x, width), clip(x + w, width));
let (y0, y1) = (clip(y, height), clip(y + h, height));
let mut region = Vec::new();
let mut row = y0;
while row < y1 {
let mut col = x0;
while col < x1 {
if inside(col, row) {
let (cx, cy) = (col as usize, row as usize);
region.push(((cx, cy), plate.ink(cx, cy).unwrap_or(0.0)));
}
col += 1.0;
for cy in y0..y1 {
for cx in x0..x1 {
region.push(((cx, cy), plate.ink(cx, cy).unwrap_or(0.0)));
}
row += 1.0;
}
let darkest = region.iter().map(|(_, ink)| *ink).fold(0.0, f32::max);
if darkest < TEXT_MIN_INK {
@@ -302,6 +299,14 @@ mod tests {
assert_eq!(skipped.failed_points(), 0);
}
#[test]
fn a_huge_text_region_samples_only_the_plate() {
let region = r#"{"at": [15, 20], "kind": "text", "region": [-3e38, -3e38, 3.4e38, 3.4e38], "inks": {"Black": 100}, "tol": 2}"#;
let huge = scene(&SCENE.replacen(r#"{"at": [45, 20], "inks": {"Cyan": 100, "Gold": 50}, "tol": 2}"#, region, 1));
let judgement = judge(&huge, &plates(true, None), 72.0, true);
assert_eq!(judgement.points[0].how.as_deref(), Some("200 glyph pixels on Black"), "{judgement:#?}");
}
#[test]
fn medians() {
assert_eq!(median(&mut []), None);
+5 -1
View File
@@ -26,7 +26,7 @@ use krilla::surface::Surface;
use krilla::text::{Font, TextDirection};
use crate::Error;
use crate::scene::{Blend, GroupCs, ImageSource, Kind, MAX_NESTING, MaskKind, Named, Object, Paint, Scene, Stroke};
use crate::scene::{Blend, GroupCs, ImageSource, Kind, MAX_IMAGE_PX, MAX_NESTING, MaskKind, Named, Object, Paint, Scene, Stroke};
/// The font used for text objects, from `scripts/fetch-pdf-test-fonts.py`.
pub const FONT_FILE: &str = "Inter-Regular.ttf";
@@ -413,6 +413,10 @@ impl Resolver<'_, '_> {
fn image(&self, source: &ImageSource, [x0, y0, w, h]: [f32; 4]) -> Result<Image, Error> {
let [px, py] = source.px;
// The writer takes scenes that skipped `Scene::validate`, so the pixel cap is enforced here too.
if !(1..=MAX_IMAGE_PX).contains(&px) || !(1..=MAX_IMAGE_PX).contains(&py) {
return Err(self.bad(&format!("image px must be 1–{MAX_IMAGE_PX}")));
}
let (width, height) = (px as usize, py as usize);
let image = if let Some(radius) = source.feather {
let mut data = Vec::with_capacity(width * height);
+5 -2
View File
@@ -15,6 +15,9 @@ use crate::structure::StructureExpect;
/// How deep placed scenes may nest.
pub const MAX_NESTING: usize = 4;
/// The most pixels an image source may ask for on either side.
pub const MAX_IMAGE_PX: u32 = 4096;
/// The plate text points find their glyphs on unless they name another.
pub const TEXT_PLATE: &str = "Black";
@@ -471,8 +474,8 @@ impl Scene {
if sources.iter().filter(|s| **s).count() != 1 {
out.push("image needs exactly one of ramp, split or feather".into());
}
if image.px[0] == 0 || image.px[1] == 0 || image.px[0] > 4096 || image.px[1] > 4096 {
out.push("image px must be 1–4096".into());
if image.px.iter().any(|px| !(1..=MAX_IMAGE_PX).contains(px)) {
out.push(format!("image px must be 1–{MAX_IMAGE_PX}"));
}
if image.alpha.as_ref().is_some_and(|a| image.feather.is_some() || !(0.0..=1.0).contains(&a.opacity)) {
out.push("image alpha needs a color image and an opacity of 0–1".into());
+105 -6
View File
@@ -5,7 +5,7 @@
//! `Separation(Gold, alt DeviceCMYK)`, `DeviceN(Cyan,Black, alt DeviceCMYK)`, `Indexed(…)`,
//! `Pattern`. Transparency group color spaces are reported per group, not in `color_spaces`.
use std::collections::{BTreeSet, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use hayro_syntax::Pdf;
@@ -96,6 +96,9 @@ pub fn summarize(pdf: &[u8]) -> Result<Summary, Error> {
shadings: res.shadings.clone(),
});
}
if !walk.truncated.is_empty() {
return Err(Error::Read(format!("resources nested more than {MAX_RESOURCE_DEPTH} levels deep were not read")));
}
let mut summary = walk.summary;
summary.device_rgb = summary.color_spaces.iter().any(|cs| cs.contains("DeviceRGB"));
Ok(summary)
@@ -122,16 +125,35 @@ impl<'a> Resources<'a> {
}
}
/// How deep forms, patterns and soft masks may reference each other's resources. Each level is
/// a recursive `Walk::content`, so this bounds the native stack a crafted PDF can use.
const MAX_RESOURCE_DEPTH: usize = 16;
#[derive(Default)]
struct Walk {
summary: Summary,
seen: HashSet<ObjectIdentifier>,
/// The shallowest depth each indirect object was expanded at.
seen: HashMap<ObjectIdentifier, usize>,
/// Streams whose `/Resources` were left unread at the depth limit, and not expanded since.
truncated: HashSet<ObjectIdentifier>,
/// Graphics states and form groups already listed, so a re-expansion does not list them twice.
recorded: HashSet<(ObjectIdentifier, String)>,
depth: usize,
}
impl Walk {
/// True the first time an indirect object is visited.
/// True the first time an indirect object is visited, and again when it is reached at a shallower
/// depth than before (its expansion may have been cut at the depth limit). Each object is
/// expanded at most `MAX_RESOURCE_DEPTH + 1` times.
fn first_visit(&mut self, id: Option<ObjectIdentifier>) -> bool {
id.is_none_or(|id| self.seen.insert(id))
let Some(id) = id else { return true };
match self.seen.get(&id) {
Some(&at) if at <= self.depth => false,
_ => {
self.seen.insert(id, self.depth);
true
}
}
}
fn group(&mut self, at: &str, group: &Dict<'_>) {
@@ -233,7 +255,9 @@ impl Walk {
self.x_object(&source, "soft mask");
}
}
self.summary.ext_g_states.push(state);
if gs.obj_id().is_none_or(|id| self.recorded.insert((id, String::new()))) {
self.summary.ext_g_states.push(state);
}
}
fn shading(&mut self, shading: &Object<'_>) {
@@ -258,7 +282,9 @@ impl Walk {
}
}
Some("Form") => {
if let Some(group) = dict.get::<Dict<'_>>(b"Group".as_slice()) {
if let Some(group) = dict.get::<Dict<'_>>(b"Group".as_slice())
&& self.recorded.insert((xobject.obj_id(), at.to_owned()))
{
self.group(at, &group);
}
self.content(xobject);
@@ -272,7 +298,17 @@ impl Walk {
self.operators(&mut UntypedIter::new(&data));
}
if let Some(res) = stream.dict().get::<Dict<'_>>(b"Resources".as_slice()) {
if self.depth >= MAX_RESOURCE_DEPTH {
// An empty dictionary names nothing, so nothing was left unread.
if res.keys().next().is_some() {
self.truncated.insert(stream.obj_id());
}
return;
}
self.truncated.remove(&stream.obj_id());
self.depth += 1;
self.resources(&Resources::of(&res));
self.depth -= 1;
}
}
}
@@ -476,3 +512,66 @@ impl StructureExpect {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A PDF whose page paints `/A` and `/B` (form XObjects given by object number), with `bodies`
/// as objects 4 onward and one more graphics-state object after them; the form objects are streams carrying their own dictionary entries.
fn pdf(a: usize, b: usize, forms: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
let mut objs = vec![
"<</Type/Catalog/Pages 2 0 R>>".to_owned(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_owned(),
format!("<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]/Resources<</XObject<</A {a} 0 R/B {b} 0 R>>>>>>"),
];
for f in forms {
objs.push(format!("<</Type/XObject/Subtype/Form/BBox[0 0 1 1]{f}/Length 0>>\nstream\n\nendstream"));
}
objs.push("<</BM/Multiply>>".to_owned()); // the graphics state the last form names
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend(format!("trailer\n<</Size {} /Root 1 0 R>>\nstartxref\n{xref}\n%%EOF", objs.len() + 1).as_bytes());
out
}
/// `n` chained forms (objects 4..4+n); the last paints with Multiply. Returns the form bodies.
fn chain(n: usize) -> Vec<String> {
let mut forms: Vec<String> = (0..n - 1).map(|i| format!("/Resources<</XObject<</X {} 0 R>>>>", 5 + i)).collect();
forms.push(format!("/Resources<</ExtGState<</G {} 0 R>>>>", 4 + n));
forms
}
#[test]
fn nesting_past_the_depth_limit_is_an_error_not_an_absence() {
let bytes = pdf(4, 4, &chain(3));
let s = summarize(&bytes).unwrap();
assert_eq!(s.ext_g_states.len(), 1, "{s:?}");
assert!(summarize(&pdf(4, 4, &chain(MAX_RESOURCE_DEPTH + 1))).is_err());
// An empty terminal /Resources at the limit leaves nothing unread.
let mut forms = chain(MAX_RESOURCE_DEPTH + 1);
*forms.last_mut().unwrap() = "/Resources<<>>".to_owned();
assert!(summarize(&pdf(4, 4, &forms)).is_ok());
}
#[test]
fn a_shared_stream_reached_shallower_later_is_still_expanded() {
let n = MAX_RESOURCE_DEPTH + 1;
let last = 4 + n - 1;
// Deep path first, then the direct one; and the reverse (keys are visited in dictionary order).
for (a, b) in [(4, last), (last, 4)] {
// One path is the whole chain, ending at the last form at the depth limit; the other is direct.
let summary = summarize(&pdf(a, b, &chain(n))).unwrap();
assert_eq!(summary.ext_g_states.len(), 1, "{a} {b}");
}
}
}
+8 -2
View File
@@ -36,6 +36,9 @@ impl Plate {
}
}
/// The most pixels a plate may hold (one byte each): A2 at 1200 dpi, with room to spare.
const MAX_PLATE_PIXELS: usize = 1 << 30;
const WIDTH: u16 = 256;
const HEIGHT: u16 = 257;
const BITS_PER_SAMPLE: u16 = 258;
@@ -88,8 +91,9 @@ pub fn read(data: &[u8]) -> Result<Plate, Error> {
};
let rows_per_strip = one(ROWS_PER_STRIP, Some(height))?.clamp(1, height.max(1));
let offsets = get(STRIP_OFFSETS)?.ok_or_else(|| bad("missing strip offsets"))?;
let size = width.checked_mul(height).ok_or_else(|| bad("plate too large"))?;
let mut ink = Vec::with_capacity(size);
let size = width.checked_mul(height).filter(|size| *size <= MAX_PLATE_PIXELS).ok_or_else(|| bad("plate too large"))?;
let mut ink = Vec::new();
ink.try_reserve_exact(size).map_err(|_| bad("plate too large"))?;
for (strip, offset) in offsets.iter().enumerate() {
let rows = rows_per_strip.min(height.saturating_sub(strip * rows_per_strip));
let bytes = data.get(*offset..offset.saturating_add(rows * width)).ok_or_else(|| bad("strip outside the file"))?;
@@ -215,5 +219,7 @@ mod tests {
// Compression (the fourth entry's value) set to PackBits.
packed[8 + 2 + 12 * 3 + 8] = 5;
assert!(read(&packed).unwrap_err().to_string().contains("uncompressed"));
// 65535 × 65535 bytes asks for more than the plate budget: refused before any allocation.
assert!(read(&encode(u16::MAX, u16::MAX, &[], 1, false)).unwrap_err().to_string().contains("too large"));
}
}