mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 06:52:21 -06:00
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:
+16
-3
@@ -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).
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user