From 0f200006fac7d186dbebcb62a0520ca416462f39 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:40:02 -0500 Subject: [PATCH] EPS: bound copies, storage, text outlines, group nesting and font discovery (#2953) * EPS: bound copies, storage, text outlines, group nesting and font discovery The PostScript interpreter's numeric copy grew the operand stack past its limit and charged nothing for the values copied; array and string constructors retained storage without charging it; show and charpath copied every glyph outline into the text path before any path or budget check; and the font pre-scan searched the whole prefix again for every eexec, as the bounding-box scan did for every rejected marker on a line. Clipping groups also nested without limit. Copies past the operand limit copy nothing; copies, arrays and strings count against the operation budget; each outline is charged and checked against the path limit before it is copied; headers are found in one pass and each is tried once; a rejected bounding-box comment is skipped to its line end; and the output builder drops groups past 256 open ones. * address review: EPS font look-back stops at the previous font; charge input characters * address review: count enclosing groups against the nesting limit in pattern and mask builders * address review: close a replaced soft mask before nesting the new one; skip patterns whose wrappers pass the depth limit --- crates/pdf/src/build.rs | 36 ++++++++++++++++++--- crates/pdf/src/page.rs | 19 +++++++++--- crates/pdf/src/ps/builtin.rs | 14 +++++++-- crates/pdf/src/ps/eps.rs | 57 +++++++++++++++++++++++++++++----- crates/pdf/src/ps/exec.rs | 12 ++++++- crates/pdf/src/ps/paths.rs | 2 +- crates/pdf/src/ps/text.rs | 7 +++++ crates/pdf/src/tests.rs | 57 ++++++++++++++++++++++++++++++++++ crates/pdf/src/tests/import.rs | 42 +++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 21 deletions(-) diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 0b1586f89..0f295a045 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -7,6 +7,9 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape /// PostScript program, or the PDF content stream. pub(crate) const MAX_PATH: usize = 1 << 20; +/// The deepest the open groups nest (the render tree and its consumers recurse through them). +const MAX_DEPTH: usize = 256; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, @@ -20,6 +23,9 @@ pub(crate) struct Builder { pub top_layers: Vec, pub skipped: Vec, pub shapes: usize, + /// Groups the finished nodes will sit under once placed (an enclosing builder's open groups + /// and the pattern or mask groups around them), counted against [`MAX_DEPTH`]. + base: usize, } pub(crate) fn group(name: &str) -> Group { @@ -28,13 +34,25 @@ pub(crate) fn group(name: &str) -> Group { impl Builder { pub fn new() -> Builder { - Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 } + Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0, base: 0 } + } + + /// A builder for content that will be placed inside this one's open groups and `wrap` more + /// groups (a pattern's `Pattern` and `Tile`, a soft mask's two), so its nesting limit counts + /// them. + pub fn nested(&self, wrap: usize) -> Builder { + Builder { base: self.base.saturating_add(self.stack.len()).saturating_add(wrap), ..Builder::new() } } pub fn depth(&self) -> usize { self.stack.len() } + /// `wrap` more groups fit under the open ones within [`MAX_DEPTH`]. + pub fn has_room(&self, wrap: usize) -> bool { + self.base.saturating_add(self.stack.len()).saturating_add(wrap) <= MAX_DEPTH + } + pub fn skip(&mut self, what: &str) { if !self.skipped.iter().any(|s| s == what) { self.skipped.push(what.to_string()); @@ -62,23 +80,33 @@ impl Builder { } } + /// Open `g` as the group drawn into from now on; past [`MAX_DEPTH`] the group is dropped + /// (what would have been drawn inside it goes to the open group) and noted as skipped. + fn push_group(&mut self, g: Group, kind: Kind) { + if self.base.saturating_add(self.stack.len()) >= MAX_DEPTH { + self.skip("group nesting limit"); + return; + } + self.stack.push((g, kind)); + } + /// Children drawn from now on are clipped by `path` (page space) until the group closes. pub fn clip(&mut self, path: BezPath, rule: FillRule) { let mut g = group("Clip Group"); g.clip.push((path, rule)); // Clipping is not a transparency group: blend modes inside reach the backdrop. g.isolated = false; - self.stack.push((g, Kind::Clip)); + self.push_group(g, Kind::Clip); } /// Open an optional-content layer (or a named group). pub fn open(&mut self, name: &str, layer: bool) { - self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + self.push_group(group(name), if layer { Kind::Layer } else { Kind::Plain }); } /// Open a group (blend mode, soft mask) closed with the graphics state. pub fn open_group(&mut self, g: Group) { - self.stack.push((g, Kind::Plain)); + self.push_group(g, Kind::Plain); } /// Add a finished node (an image, pattern tiles). diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 0ebc0421d..ba67b39aa 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -669,7 +669,12 @@ impl<'a> Interp<'a> { let bc = f.get(&sm, "BC").map(|b| f.nums(b)).unwrap_or_default(); let gcs = f.get_dict(&gd, "Group").and_then(|gr| f.get(gr, "CS")).map(|c| color_space(f, c, Some(res))).unwrap_or(Cs::Rgb); let backdrop = if bc.is_empty() { [0.0; 3] } else { gcs.to_rgb(&bc) }; - let saved_b = std::mem::replace(&mut self.b, Builder::new()); + // The mask it replaces closes first: its groups don't enclose the new one. + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + let inner = self.b.nested(2); + let saved_b = std::mem::replace(&mut self.b, inner); let ctm = self.gs.ctm; let saved_gs = std::mem::replace(&mut self.gs, GState { ctm, ..GState::default() }); let saved_stack = std::mem::take(&mut self.saved); @@ -681,9 +686,6 @@ impl<'a> Interp<'a> { for s in sk { self.b.skip(&s); } - if let Some(d) = self.gs.mask_depth { - self.close_to(d); - } let mut content = Group::new("Soft Mask"); content.children = nodes; let mut grp = Group::new("Soft Mask Group"); @@ -784,8 +786,15 @@ impl<'a> Interp<'a> { if depth > 8 { return; } + // The `Pattern` and `Tile` wrappers must fit under the open groups too. + if !self.b.has_room(2) { + self.b.skip("group nesting limit"); + return; + } // The cell, drawn once in pattern space. - let saved_b = std::mem::replace(&mut self.b, Builder::new()); + // The cell ends up under the open groups and this pattern's `Pattern` and `Tile`. + let inner = self.b.nested(2); + let saved_b = std::mem::replace(&mut self.b, inner); let saved_gs = std::mem::take(&mut self.gs); let saved_stack = std::mem::take(&mut self.saved); let saved_path = std::mem::take(&mut self.path); diff --git a/crates/pdf/src/ps/builtin.rs b/crates/pdf/src/ps/builtin.rs index dcafa9417..a19aeb6da 100644 --- a/crates/pdf/src/ps/builtin.rs +++ b/crates/pdf/src/ps/builtin.rs @@ -1,4 +1,5 @@ use super::eps::find_from; +use super::exec::MAX_STACK; use super::scanner::Src; use super::text::encoding_array; use super::value::{Flow, V, array, matrix_of, matrix_val, new_dict}; @@ -181,8 +182,13 @@ impl Interp { match self.pop() { V::Num(k) => { let k = (k.max(0.0) as usize).min(self.st.len()); - let tail: Vec = self.st[self.st.len() - k..].to_vec(); - self.st.extend(tail); + // A copy past the operand limit copies nothing, and the values copied + // count as operations (one for eight): repeated doubling spends the budget. + if self.st.len().saturating_add(k) <= MAX_STACK { + self.charge(k / 8); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } } V::Array(dst) => { let s = self.pop().items().unwrap_or_default(); @@ -507,12 +513,16 @@ impl Interp { fn try_collection_op(&mut self, n: &str) -> bool { match n { // Arrays and strings. + // The storage made counts as operations (a value, or eight bytes, each), so a loop + // of large constructors spends the budget rather than the memory. "array" => { let k = self.popn().clamp(0.0, 1e6) as usize; + self.charge(k); self.push(array(vec![V::Null; k])); } "string" => { let k = self.popn().clamp(0.0, 1e7) as usize; + self.charge(k / 8); self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); } "length" => { diff --git a/crates/pdf/src/ps/eps.rs b/crates/pdf/src/ps/eps.rs index 90d60cfbe..8fb72698a 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -19,7 +19,8 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { return Some([v[0], v[1], v[2], v[3]]); } - from = s; + // A rejected comment is done with: the scan goes on past its line, not over it again. + from = e; } None }; @@ -31,8 +32,15 @@ pub(super) fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) } -fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option { - hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle) +/// Every position of `needle` in `hay`, in order (one pass over the file). +fn positions(hay: &[u8], needle: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(p) = find_from(hay, needle, from) { + out.push(p); + from = p + needle.len(); + } + out } /// The name after `key` (`/FontName /Name`). @@ -48,20 +56,26 @@ fn name_after(b: &[u8], key: &[u8]) -> Option { /// … cleartomark`, hexadecimal or binary) and CFF font sets (`… StartData `). pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> { let mut out = HashMap::new(); + // The headers are found once, in one pass each; every `eexec` then takes the last header + // before it that no earlier `eexec` has used, so no prefix of the file is searched again. + let mut heads = positions(ps, b"%!PS-AdobeFont"); + heads.extend(positions(ps, b"%!FontType1")); + heads.sort_unstable(); + let names = positions(ps, b"/FontName"); let mut from = 0; + let mut used = 0; while let Some(ee) = find_from(ps, b"eexec", from) { from = ee + 5; - let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"] - .iter() - .filter_map(|h| rfind_before(ps, h, ee)) - .max() - .or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512))); + let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used); + // The fallback look-back stops at the previous font's end, so it cannot pick up its header. + let start = last_before(&heads).or_else(|| last_before(&names).map(|p| p.saturating_sub(512).max(used))); let Some(start) = start else { continue }; let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); if let Some(name) = name_after(&ps[start..ee], b"/FontName") { out.insert(name, (Rc::new(ps[start..end].to_vec()), false)); } from = end; + used = end; } let mut from = 0; while let Some(sd) = find_from(ps, b"StartData", from) { @@ -77,3 +91,30 @@ pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> } out } + +#[cfg(test)] +mod tests { + use super::embedded_fonts; + + #[test] + fn font_discovery_looks_back_once_per_header() { + // 20,000 `eexec` words before any font header: each used to search the whole prefix + // for a header again. The font after them is still found. + let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + ps.extend_from_slice("eexec ".repeat(20_000).as_bytes()); + ps.extend_from_slice(b"\n%!PS-AdobeFont-1.0: Real\n/FontName /Real def\ncurrentfile eexec\n0000\ncleartomark\n"); + let fonts = embedded_fonts(&ps); + assert_eq!(fonts.len(), 1); + assert!(fonts.contains_key("Real")); + } + + #[test] + fn header_less_font_after_a_short_one_does_not_take_its_header() { + let a = "%!PS-AdobeFont-1.0: A\n/FontName /A def\ncurrentfile eexec\n0000\ncleartomark\n"; + let b = "/FontName /B def\ncurrentfile eexec\n0000\ncleartomark\n"; + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n{a}{b}"); + let fonts = embedded_fonts(ps.as_bytes()); + let (data, _) = fonts.get("B").expect("second font found"); + assert!(!String::from_utf8_lossy(data).contains("AdobeFont"), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/pdf/src/ps/exec.rs b/crates/pdf/src/ps/exec.rs index 27697527c..233ff82b0 100644 --- a/crates/pdf/src/ps/exec.rs +++ b/crates/pdf/src/ps/exec.rs @@ -6,6 +6,10 @@ use super::{Affine, BezPath, Builder, Cap, HashMap, Interp, Join}; /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; +/// The most values the operand stack holds: a push past it is dropped, and `copy` copies +/// nothing rather than grow the stack past it. +pub(super) const MAX_STACK: usize = 100_000; + impl Interp { pub fn new(page: [f64; 4]) -> Interp { Interp { @@ -56,11 +60,17 @@ impl Interp { } pub(super) fn push(&mut self, v: V) { - if self.st.len() < 100_000 { + if self.st.len() < MAX_STACK { self.st.push(v); } } + /// Count `n` units of work (values copied, storage allocated) against the budget without + /// stopping the program here: once it is spent, the next operator stops it. + pub(super) fn charge(&mut self, n: usize) { + self.budget = self.budget.saturating_sub(n); + } + pub(super) fn lookup(&self, k: &str) -> Option { self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) } diff --git a/crates/pdf/src/ps/paths.rs b/crates/pdf/src/ps/paths.rs index b1fe07092..30b5a7087 100644 --- a/crates/pdf/src/ps/paths.rs +++ b/crates/pdf/src/ps/paths.rs @@ -22,7 +22,7 @@ impl Interp { } /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. - fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + pub(super) fn path_fits(&mut self, len: usize) -> Result<(), Flow> { if len <= MAX_PATH { return Ok(()); } diff --git a/crates/pdf/src/ps/text.rs b/crates/pdf/src/ps/text.rs index b138965bb..d4cee07f8 100644 --- a/crates/pdf/src/ps/text.rs +++ b/crates/pdf/src/ps/text.rs @@ -95,7 +95,14 @@ impl Interp { let ctm = self.gs.ctm; for (i, &code) in bytes.iter().enumerate() { let code = code as u32; + // Every input character costs at least one unit, so empty outlines are not free. + self.spend(1)?; if let Some(g) = font.glyph(code) { + // Each outline element counts against the path limit and the budget before it + // is copied, so a long string cannot expand into unbounded geometry. + let n = g.elements().len(); + self.path_fits(path.elements().len().saturating_add(n))?; + self.spend(n)?; path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone()); } text.push_str(&font.unicode(code)); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index cd3eecdb2..767930cd9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -748,6 +748,63 @@ fn tiling_patterns_coloured_and_uncoloured() { assert_eq!(at(115, 15)[3], 0.0); } +/// A pattern cell nests under the page's open clipping groups and the pattern's own groups, and +/// the render tree stays within the group depth limit that counts all of them. +#[test] +fn a_pattern_cell_counts_the_groups_it_is_nested_in() { + fn depth(g: &effectcraft_svg::Group) -> usize { + 1 + g.children.iter().filter_map(|c| if let effectcraft_svg::Node::Group(g) = c { Some(depth(g)) } else { None }).max().unwrap_or(0) + } + let clips = "0 0 200 100 re W n ".repeat(250); + let cell = format!("{} 1 0 0 rg 0 0 10 10 re f", "0 0 20 20 re W n ".repeat(250)).into_bytes(); + let bytes = page_pdf( + &format!("{clips} /Pattern cs /P1 scn 0 0 100 100 re f"), + "/Pattern << /P1 10 0 R >>", + vec![(10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell))], + ); + let doc = parse(&bytes).unwrap(); + assert!(depth(&doc.root) <= 260, "{}", depth(&doc.root)); + assert!(doc.skipped.iter().any(|s| s == "group nesting limit"), "{:?}", doc.skipped); +} + +/// A pattern whose `Pattern` and `Tile` wrappers would pass the depth limit is skipped, not built +/// past it. +#[test] +fn a_pattern_whose_wrappers_pass_the_depth_limit_is_skipped() { + let clips = "0 0 200 100 re W n ".repeat(255); + let bytes = page_pdf( + &format!("{clips} /Pattern cs /P1 scn 0 0 100 100 re f"), + "/Pattern << /P1 10 0 R >>", + vec![( + 10, + "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), + Some(b"0 0 10 10 re f".to_vec()), + )], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().any(|s| s == "group nesting limit"), "{:?}", doc.skipped); +} + +/// A soft mask replacing another is nested under what is open once the old one has closed, so +/// groups inside the old mask don't count against the new mask's content. +#[test] +fn a_replaced_soft_mask_does_not_count_against_the_new_one() { + let form = |n: &str| { + ( + n.parse().unwrap(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".to_string(), + Some(format!("{} 1 g 0 0 100 100 re f", "0 0 200 100 re W n ".repeat(250)).into_bytes()), + ) + }; + let bytes = page_pdf( + &format!("/GS1 gs {} /GS2 gs 1 0 0 rg 0 0 200 100 re f", "0 0 200 100 re W n ".repeat(250)), + "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> /GS2 << /SMask << /S /Luminosity /G 11 0 R >> >> >>", + vec![form("10"), form("11")], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().all(|s| s != "group nesting limit"), "{:?}", doc.skipped); +} + /// Build isolated interpreter objects without presenting a function or colour space as a /// document catalog. Complete-document parsing is covered by the page and reader fixtures. fn object_file(objs: Objs) -> object::File { diff --git a/crates/pdf/src/tests/import.rs b/crates/pdf/src/tests/import.rs index c7dcbee57..516ff054d 100644 --- a/crates/pdf/src/tests/import.rs +++ b/crates/pdf/src/tests/import.rs @@ -513,6 +513,9 @@ fn paths_stop_at_the_path_limit() { // The outlines of 100,000 H's, 13 elements each. "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ 0 0 moveto s false charpath", + // The same outlines shown: the text path is bounded like the current path. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s show", // 210,000 rectangles of five elements each. "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", ] { @@ -528,6 +531,45 @@ fn paths_stop_at_the_path_limit() { assert_eq!(doc.flatten().len(), 1); } +#[test] +fn copies_past_the_operand_limit_copy_nothing() { + // `count copy` doubles the operand stack: 40 doublings would be 2^40 values. A copy that would + // pass the operand limit (100,000) copies nothing, so the program runs on to its end. + let doc = eps_soon("0 0 10 10 rectfill 1 40 { count copy } repeat clear 20 20 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 2); +} + +#[test] +fn storage_made_in_a_loop_runs_out_of_the_budget() { + // Each value of an array and each eight bytes of a string count as an operation when they + // are made, so a loop keeping large arrays or strings spends the budget, not the memory. + for body in ["/a 1000 array def 0 1 999 { a exch 1000000 array put } for", "/a 1000 array def 0 1 999 { a exch 10000000 string put } for"] { + runs_out_of_budget(body); + } +} + +#[test] +fn groups_stop_nesting_at_the_depth_limit() { + // Every `rectclip` opens a clipping group inside the last: past 256 open groups the group is + // dropped (and noted), and what is drawn inside goes to the open one. + let doc = eps_soon("300 { 0 0 100 100 rectclip } repeat 0 0 10 10 rectfill"); + assert_eq!(doc.skipped, ["group nesting limit"]); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn bounding_box_comments_are_scanned_once() { + // A rejected `%%BoundingBox:` comment is not scanned again from each later marker on its + // line: 20,000 markers on one line would otherwise take quadratic work. + let mut src = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + src.extend_from_slice("%%BoundingBox: x ".repeat(20_000).as_bytes()); + src.extend_from_slice(b"\n%%BoundingBox: 0 0 50 50\n0 0 10 10 rectfill\n"); + let doc = parse(&src).unwrap(); + assert_eq!((doc.width, doc.height), (50.0, 50.0)); + assert_eq!(doc.flatten().len(), 1); +} + #[test] fn pdf_paths_stop_at_the_path_limit() { // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each)