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.
This commit is contained in:
Dylan M. Taylor
2026-11-03 04:26:24 +00:00
parent 7870b5916a
commit 62ef74067f
7 changed files with 125 additions and 15 deletions
+16 -3
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,
@@ -62,23 +65,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.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).
+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" => {
+38 -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,25 @@ 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);
let start = last_before(&heads).or_else(|| last_before(&names).map(|p| p.saturating_sub(512)));
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 +90,20 @@ 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"));
}
}
+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(());
}
+5
View File
@@ -96,6 +96,11 @@ impl Interp {
for (i, &code) in bytes.iter().enumerate() {
let code = code as u32;
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));
+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)