From 4ba6ff32b8a84cfe505e375cf6a66175cc3ac785 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 15:12:55 -0500 Subject: [PATCH] PDF functions and mesh shadings: charge retained values, refuse short tables, bound lattice rows and raster work (#2972) * PDF functions and mesh shadings: charge retained values, refuse short tables, bound lattice rows and raster work Depth-limit fallbacks were not charged to the function node budget; cached copies of a function deep-copied its coefficient and program vectors while only its nodes were charged; sampled functions made up missing samples as zeros; calculator programs had no size limit; a lattice row was filled before any limit applied; and overlapping mesh triangles each scanned their whole bounding box. The node is charged before the depth check; coefficients, bounds, encode arrays and calculator operators are charged (eight to a node) so cached copies carry their cost; a sampled function shorter than its table is unsupported; calculator programs hold at most 4,096 operators; lattice rows past the triangle limit are refused and vertices stop at it; and triangle bounding boxes share a budget of 64 passes over the raster. * address review: bound calculator token count while parsing * address review: count skipped calculator tokens toward MAX_CALC_OPS * address review: skip arrays and dictionaries in a calculator program without building them * address review: scalar tokens in a skipped compound are passed over without building them --- crates/pdf/src/color.rs | 148 ++++++++++++++++++++++++++------ crates/pdf/src/object.rs | 85 ++++++++++++++++++ crates/pdf/src/shading.rs | 13 ++- crates/pdf/src/tests.rs | 29 +++++++ crates/pdf/src/tests/shading.rs | 29 +++++++ 5 files changed, 277 insertions(+), 27 deletions(-) diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index 6943476bc..5a7e9af86 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -215,19 +215,44 @@ pub enum PsOp { Proc(Vec), } -fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { +/// Parse a procedure. `ops` counts the tokens read (those skipped as unsupported included); past +/// [`MAX_CALC_OPS`] the rest is not read, so an oversized program is never fully scanned or built +/// (the caller rejects it when `ops` ends past the cap). +fn ps_parse(lx: &mut crate::object::Lexer, depth: usize, ops: &mut usize) -> Vec { let mut out = vec![]; - while let Some(o) = lx.next() { - match o { - Obj::Num(n) => out.push(PsOp::Num(n)), - Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })), - Obj::Op(op) if op == "{" && depth < 32 => out.push(PsOp::Proc(ps_parse(lx, depth + 1))), - // Nested too deep: skip the procedure's opening brace (never push it as an operator). - Obj::Op(op) if op == "{" => {} - Obj::Op(op) if op == "}" => return out, - Obj::Op(op) => out.push(PsOp::Op(op)), - _ => {} + loop { + // A calculator holds no arrays or dictionaries: one is skipped as a single token without + // being built (the lexer would allocate every element before this counted it). + if lx.at_compound() { + if *ops > MAX_CALC_OPS { + break; + } + lx.skip_compound(); + *ops += 1; + continue; } + let Some(o) = lx.next() else { break }; + if *ops > MAX_CALC_OPS { + break; + } + let op = match o { + Obj::Num(n) => PsOp::Num(n), + Obj::Bool(b) => PsOp::Num(if b { 1.0 } else { 0.0 }), + Obj::Op(op) if op == "{" && depth < 32 => PsOp::Proc(ps_parse(lx, depth + 1, ops)), + // Nested too deep: skip the procedure's opening brace (never push it as an operator). + Obj::Op(op) if op == "{" => { + *ops += 1; + continue; + } + Obj::Op(op) if op == "}" => return out, + Obj::Op(op) => PsOp::Op(op), + _ => { + *ops += 1; + continue; + } + }; + *ops += 1; + out.push(op); } out } @@ -243,6 +268,15 @@ fn ps_push(st: &mut Vec, v: f64) { } } +/// The most operators and operands a calculator program may hold (nested procedures included). +/// It has no loops, so this bounds what one evaluation runs; real programs hold a few dozen. +const MAX_CALC_OPS: usize = 4096; + +/// The operators and operands in `code`, nested procedures included. +fn ps_len(code: &[PsOp]) -> usize { + code.iter().map(|op| if let PsOp::Proc(p) = op { 1 + ps_len(p) } else { 1 }).sum() +} + fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { if depth > 32 { return; @@ -431,6 +465,12 @@ impl Budget { Budget { nodes: MAX_NODES, samples: MAX_SAMPLES, overrun: false } } + /// Charge `n` retained values (coefficients, bounds, program operators) as nodes, eight to a + /// node, so a cached copy of a function is charged for what it holds, not just its node. + fn take_values(&mut self, n: usize) -> bool { + self.take_nodes(n.div_ceil(8)) + } + /// Charge `n` nodes; `false` (and the read is abandoned) once the cap is passed. fn take_nodes(&mut self, n: usize) -> bool { match self.nodes.checked_sub(n) { @@ -484,7 +524,8 @@ impl Func { /// [`Func::read`] nested `depth` levels deep, charging `budget`: unsupported past [`MAX_DEPTH`] /// or once the budget is spent. fn read_at(file: &File, o: &Obj, depth: usize, budget: &mut Budget) -> Func { - if depth >= MAX_DEPTH || !budget.take_nodes(1) { + // Charged first: the fallback past the depth limit is a node a copy keeps too. + if !budget.take_nodes(1) || depth >= MAX_DEPTH { return Func::Unsupported; } let o = file.resolve(o); @@ -499,12 +540,14 @@ impl Func { let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default(); let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)]; match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 { - 2 => Func::Exp { - domain, - c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]), - c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]), - n: file.get_num(d, "N").unwrap_or(1.0), - }, + 2 => { + let c0 = file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]); + let c1 = file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]); + if !budget.take_values(c0.len() + c1.len()) { + return Func::Unsupported; + } + Func::Exp { domain, c0, c1, n: file.get_num(d, "N").unwrap_or(1.0) } + } 3 => { // A stitching function's pieces: no element cap (a repeating gradient lists one // piece once per repeat); the budget bounds the total work. @@ -513,12 +556,12 @@ impl Func { None => Some(vec![]), }; let Some(funcs) = funcs else { return Func::Unsupported }; - Func::Stitch { - domain, - funcs, - bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), - encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), + let bounds = file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(); + let encode = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); + if !budget.take_values(bounds.len() + encode.len()) { + return Func::Unsupported; } + Func::Stitch { domain, funcs, bounds, encode } } 0 => { let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; @@ -537,6 +580,10 @@ impl Func { if !budget.take_samples(count) { return Func::Unsupported; } + // The stream must hold the table: missing bytes are not made up as zeros. + if count.checked_mul(bps).is_none_or(|bits| bits.div_ceil(8) > data.len()) { + return Func::Unsupported; + } let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)]; let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone()); @@ -567,10 +614,15 @@ impl Func { let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; let mut lx = crate::object::Lexer::new(&data, 0); // The program is one procedure: `{ … }`. - let code = match ps_parse(&mut lx, 0).into_iter().next() { + let mut scanned = 0; + let code = match ps_parse(&mut lx, 0, &mut scanned).into_iter().next() { Some(PsOp::Proc(p)) => p, _ => return Func::Unsupported, }; + let ops = ps_len(&code); + if scanned > MAX_CALC_OPS || ops > MAX_CALC_OPS || !budget.take_values(ops) { + return Func::Unsupported; + } Func::Calc { domain: dom, range: file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(), code } } _ => Func::Unsupported, @@ -756,12 +808,56 @@ mod tests { assert_eq!(sample_count(1 << 22, usize::MAX / (1 << 22) + 1), None); } + #[test] + fn oversized_calculator_program_is_cut_short() { + let src = format!("{{ {}}}", "1 ".repeat(MAX_CALC_OPS + 10)); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; + assert_eq!(ps_len(&code), MAX_CALC_OPS + 1); + } + + #[test] + fn arrays_and_dictionaries_in_a_calculator_are_skipped_whole() { + // Strings with brackets, nested arrays and a dictionary are one skipped token each; the + // operators around them still parse. + let src = b"{ 1 [ (a]b) [1 2] << /K [3 4] /S (>>) >> 5 ] 2 add << /A 1 >> mul }"; + let mut lx = crate::object::Lexer::new(src, 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; + assert_eq!(code, vec![PsOp::Num(1.0), PsOp::Num(2.0), PsOp::Op("add".into()), PsOp::Op("mul".into())]); + // A huge array is skipped once the cap is passed, not read to its end. + let src = format!("{{ [ {}] 1 }}", "0 ".repeat(MAX_CALC_OPS * 100)); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let mut scanned = 0; + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut scanned).into_iter().next() else { panic!() }; + assert_eq!(code, vec![PsOp::Num(1.0)]); + // A large string inside a skipped array is passed over, not decoded: escapes, nested + // parentheses, a hexadecimal string and names all end where the lexer would end them. + let big = format!("{{ [ ({}) <{}> /{} (a\\)b(c)) 7 ] 1 }}", "x".repeat(1 << 20), "ab".repeat(1 << 19), "N".repeat(1 << 10)); + let mut lx = crate::object::Lexer::new(big.as_bytes(), 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; + assert_eq!(code, vec![PsOp::Num(1.0)]); + // An unclosed one ends at the end of the data. + let mut lx = crate::object::Lexer::new(b"[ 1 2 ", 0); + lx.skip_compound(); + assert_eq!(lx.pos, 6); + } + + #[test] + fn skipped_calculator_tokens_count_toward_the_cap() { + // `null` is not a calculator token: skipped, but still scanned. + let src = format!("{{ {}}}", "null ".repeat(MAX_CALC_OPS + 10)); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let mut scanned = 0; + ps_parse(&mut lx, 0, &mut scanned); + assert!(scanned > MAX_CALC_OPS && scanned < MAX_CALC_OPS + 10, "parsing stops once the cap is passed ({scanned})"); + } + #[test] fn calculator_functions() { // Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 } let code = b"{ 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } { 0.25 mul } ifelse 0 }"; let mut lx = crate::object::Lexer::new(code, 0); - let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; let f = Func::Calc { domain: vec![0.0, 1.0, 0.0, 1.0], range: vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0], code }; assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]); assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]); @@ -771,7 +867,7 @@ mod tests { fn run(src: &str, mut st: Vec) -> Vec { let src = format!("{{ {src} }}"); let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); - let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; ps_run(&code, &mut st, 0); st } diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index 79a91dde8..df475a4bb 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -82,6 +82,91 @@ impl<'a> Lexer<'a> { } } + /// Whether the next token opens an array or a dictionary (skipping white space first). + pub fn at_compound(&mut self) -> bool { + self.skip_ws(); + match self.data.get(self.pos) { + Some(b'[') => true, + Some(b'<') => self.data.get(self.pos + 1) == Some(&b'<'), + _ => false, + } + } + + /// Skip the array or dictionary at the current position (see [`Lexer::at_compound`]) as + /// [`Lexer::next`] would read it, without building it: for readers that have no use for one + /// and must not allocate a vector per element of a hostile stream. + pub fn skip_compound(&mut self) { + let mut open = 0usize; + loop { + self.skip_ws(); + match self.data.get(self.pos) { + None => return, + Some(b'[') => { + open += 1; + self.pos += 1; + } + Some(b'<') if self.data.get(self.pos + 1) == Some(&b'<') => { + open += 1; + self.pos += 2; + } + Some(b']') | Some(b'>') if self.data[self.pos] == b']' || self.data.get(self.pos + 1) == Some(&b'>') => { + open = open.saturating_sub(1); + self.pos += if self.data[self.pos] == b']' { 1 } else { 2 }; + if open == 0 { + return; + } + } + // Anything else is one token (a string, name, number or word), passed over + // without building it. + Some(_) => self.skip_scalar(), + } + } + } + + /// Pass over one token that is not an array or dictionary bracket, as [`Lexer::next`] reads + /// it but without building its value. + fn skip_scalar(&mut self) { + let Some(&c) = self.data.get(self.pos) else { return }; + match c { + b'(' => { + // A literal string: balanced parentheses, a backslash escapes the next byte. + let mut depth = 0usize; + while let Some(&d) = self.data.get(self.pos) { + self.pos += 1; + match d { + b'\\' => self.pos += 1, + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + self.pos = self.pos.min(self.data.len()); + } + b'<' => { + // A hexadecimal string. + while self.data.get(self.pos).is_some_and(|d| *d != b'>') { + self.pos += 1; + } + self.pos = (self.pos + 1).min(self.data.len()); + } + b'/' => { + self.pos += 1; + self.word(); + } + b']' | b'{' | b'}' | b')' | b'>' => self.pos += 1, + _ => { + if self.word().is_empty() { + self.pos += 1; + } + } + } + } + fn word(&mut self) -> &'a [u8] { let s = self.pos; while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) { diff --git a/crates/pdf/src/shading.rs b/crates/pdf/src/shading.rs index df5e5f9c7..aa83adebf 100644 --- a/crates/pdf/src/shading.rs +++ b/crates/pdf/src/shading.rs @@ -18,6 +18,8 @@ const MAX_SIDE: f64 = 1024.0; const MAX_SCALE: f64 = 4.0; /// The most triangles one shading draws. const MAX_TRIANGLES: usize = 2_000_000; +/// How many times over the mesh raster may be covered by triangle bounding boxes in all. +const MAX_RASTER_PASSES: usize = 64; /// A mesh vertex: page-space position and its value (RGB, or `t` in `[0]` with a function). #[derive(Clone, Copy, Debug, PartialEq)] @@ -234,13 +236,17 @@ fn free_form(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine) -> Vec<[V; 3] /// Type 5: a lattice of `per_row` vertices per row, two triangles per cell. fn lattice(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine, per_row: usize) -> Vec<[V; 3]> { - if per_row < 2 { + if !(2..=MAX_TRIANGLES).contains(&per_row) { return vec![]; } let mut rows: Vec> = vec![]; let mut row = vec![]; while let (Some(p), Some(c)) = (l.point(r), l.comps(r)) { r.align(); + // Vertices kept, the unfinished row's included, stay within the triangle limit. + if (rows.len() * per_row).saturating_add(row.len()) >= MAX_TRIANGLES { + break; + } row.push(V { p: m * p, c: colors.value(&c) }); if row.len() == per_row { rows.push(std::mem::take(&mut row)); @@ -447,6 +453,9 @@ fn rasterize(tris: &[[V; 3]], colors: &Colors, page_box: [f64; 4]) -> Option Option>", "2 0 R ".repeat(1000)), None), + (2, format!("<< /FunctionType 2 /Domain [0 1] /C0 {c} /C1 {c} /N 1 >>"), None), + ]; + assert_eq!(read_function(objs), color::Func::Unsupported); +} + #[test] fn colour_space_chain_of_depth_3() { // Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white). diff --git a/crates/pdf/src/tests/shading.rs b/crates/pdf/src/tests/shading.rs index 8e96193ab..4b5f9645d 100644 --- a/crates/pdf/src/tests/shading.rs +++ b/crates/pdf/src/tests/shading.rs @@ -262,3 +262,32 @@ fn corrupt_mesh_data_draws_what_parses() { let doc = parse(&bytes).unwrap(); assert!(doc.skipped.iter().any(|s| s.contains("layout")), "{:?}", doc.skipped); } + +#[test] +fn overlapping_mesh_triangles_stop_at_the_raster_budget() { + // 20,000 triangles each covering the whole page would test every sample 20,000 times; the + // raster budget stops after about 64 passes, and the shading is still drawn. + let mut m = Mesh::default(); + for _ in 0..20_000 { + m.flag(0).pt(0, 0).c(&[255, 0, 0]); + m.flag(0).pt(400, 0).c(&[255, 0, 0]); + m.flag(0).pt(0, 400).c(&[255, 0, 0]); + } + let sh = format!("<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"); + let bytes = page_pdf("/Sh sh", "/Shading << /Sh 10 0 R >>", vec![(10, sh, Some(m.0))]); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(parse(&bytes)).ok()); + let doc = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the mesh finishes").unwrap(); + assert!(render(&doc).get(50, 50)[0] > 0.99); +} + +#[test] +fn a_lattice_row_longer_than_the_triangle_limit_draws_nothing() { + let mut m = Mesh::default(); + for i in 0..8 { + m.pt(i * 10, 0).c(&[255, 0, 0]); + } + let sh = format!("<< /ShadingType 5 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /VerticesPerRow 3000000 {RGB_DECODE} >>"); + let doc = parse(&page_pdf("/Sh sh", "/Shading << /Sh 10 0 R >>", vec![(10, sh, Some(m.0))])).unwrap(); + assert!(doc.flatten().is_empty()); +}