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
This commit is contained in:
Dylan M. Taylor
2026-11-11 14:40:02 -05:00
committed by GitHub
parent 0e248459d2
commit 0f200006fa
9 changed files with 225 additions and 21 deletions
+32 -4
View File
@@ -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<bool>,
pub skipped: Vec<String>,
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).
+14 -5
View File
@@ -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);
+12 -2
View File
@@ -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<V> = 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<V> = 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" => {
+49 -8
View File
@@ -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<usize>
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option<usize> {
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<usize> {
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<String> {
/// … cleartomark`, hexadecimal or binary) and CFF font sets (`… <length> StartData <bytes>`).
pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap<String, (Rc<Vec<u8>>, 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<String, (Rc<Vec<u8>>, 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));
}
}
+11 -1
View File
@@ -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<V> {
self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned())
}
+1 -1
View File
@@ -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(());
}
+7
View File
@@ -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));
+57
View File
@@ -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 {
+42
View File
@@ -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)