mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 00:42:40 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user