Merge pull request #1527

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:34 -04:00
4 changed files with 228 additions and 53 deletions
+4
View File
@@ -3,6 +3,10 @@
use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke};
/// The most elements a path being built holds: an operator that would add more stops the
/// PostScript program, or the PDF content stream.
pub(crate) const MAX_PATH: usize = 1 << 20;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Plain,
+7 -2
View File
@@ -12,7 +12,7 @@ use effectcraft_svg::{
};
use kurbo::{Point, Shape as _};
use crate::build::Builder;
use crate::build::{Builder, MAX_PATH};
use crate::color::{Cs, Func, color_space, text_string};
use crate::font::Font;
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
@@ -281,7 +281,12 @@ impl<'a> Interp<'a> {
self.ext_gstate(&g, res, depth);
}
}
// Path construction.
// Path construction. Each operator adds at most five elements (`re`), so when fewer
// than five more fit within `MAX_PATH`, the operator stops the stream.
"m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => {
self.b.skip("content (path limit)");
return;
}
"m" => {
let p = Point::new(n(0), n(1));
self.path.move_to(p);
+123 -51
View File
@@ -14,7 +14,7 @@ use std::rc::Rc;
use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke};
use kurbo::{PathEl, Point};
use crate::build::Builder;
use crate::build::{Builder, MAX_PATH};
use crate::font::Font;
use crate::object::{ascii85, is_white, parse_num};
@@ -441,7 +441,14 @@ impl Interp {
break;
}
}
other => self.push(other.clone()),
// Other items count against the budget as names do.
other => {
r = self.spend(1);
if r.is_err() {
break;
}
self.push(other.clone());
}
}
}
self.calls -= 1;
@@ -456,11 +463,7 @@ impl Interp {
}
fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> {
if self.budget == 0 {
self.b.skip("PostScript (operation limit)");
return Err(Flow::Quit);
}
self.budget -= 1;
self.spend(1)?;
if let Some(v) = self.lookup(n) {
return match v {
V::Proc(_) => self.exec_obj(v, src),
@@ -473,6 +476,21 @@ impl Interp {
self.builtin(n, src)
}
/// Count `n` operations against the budget, and stop the program when fewer are left. An
/// operator that makes, reads or copies path elements counts each one as an operation.
fn spend(&mut self, n: usize) -> Result<(), Flow> {
match self.budget.checked_sub(n) {
Some(left) => {
self.budget = left;
Ok(())
}
None => {
self.b.skip("PostScript (operation limit)");
Err(Flow::Quit)
}
}
}
fn device_scale(&self) -> f64 {
self.gs.ctm.determinant().abs().sqrt()
}
@@ -483,13 +501,34 @@ impl Interp {
Point::new(x, y)
}
fn move_to(&mut self, p: Point) {
/// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`].
fn path_fits(&mut self, len: usize) -> Result<(), Flow> {
if len <= MAX_PATH {
return Ok(());
}
self.b.skip("PostScript (path limit)");
Err(Flow::Quit)
}
/// [`Self::path_fits`] for the current path with `n` more elements.
fn room(&mut self, n: usize) -> Result<(), Flow> {
self.path_fits(self.gs.path.elements().len().saturating_add(n))
}
fn move_to(&mut self, p: Point) -> Result<(), Flow> {
// A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`).
if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) {
self.gs.path.pop();
}
self.room(1)?;
let q = self.gs.ctm * p;
self.gs.path.move_to(q);
self.gs.cur = Some(q);
self.gs.start = Some(q);
Ok(())
}
fn line_to(&mut self, p: Point) {
fn line_to(&mut self, p: Point) -> Result<(), Flow> {
self.room(1 + usize::from(self.gs.cur.is_none()))?;
let q = self.gs.ctm * p;
if self.gs.cur.is_none() {
self.gs.path.move_to(q);
@@ -497,20 +536,23 @@ impl Interp {
}
self.gs.path.line_to(q);
self.gs.cur = Some(q);
Ok(())
}
fn curve_to(&mut self, a: Point, b: Point, c: Point) {
fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> {
self.room(1 + usize::from(self.gs.cur.is_none()))?;
let m = self.gs.ctm;
if self.gs.cur.is_none() {
self.gs.path.move_to(m * a);
}
self.gs.path.curve_to(m * a, m * b, m * c);
self.gs.cur = Some(m * c);
Ok(())
}
fn current_user(&self) -> Point {
self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default()
}
fn arc(&mut self, neg: bool) {
fn arc(&mut self, neg: bool) -> Result<(), Flow> {
let a2 = self.popn();
let a1 = self.popn();
let r = self.popn();
@@ -540,16 +582,23 @@ impl Interp {
let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians());
let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin());
if self.gs.cur.is_some() {
self.line_to(start);
self.line_to(start)?;
} else {
self.move_to(start);
self.move_to(start)?;
}
let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0);
let m = self.gs.ctm;
arc.to_cubic_beziers(0.1, |p1, p2, p3| {
// kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a
// quarter turn at a radius of 10^300, more than a usize can count), so each one counts
// against the budget and the path limit.
let mut curves = arc.append_iter(0.1);
while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() {
self.room(1)?;
self.spend(1)?;
self.gs.path.curve_to(m * p1, m * p2, m * p3);
self.gs.cur = Some(m * p3);
});
}
Ok(())
}
fn paint(&mut self, fill: Option<FillRule>, stroke: bool) {
@@ -569,11 +618,16 @@ impl Interp {
self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st);
}
fn rect_path(&mut self) -> Vec<BezPath> {
fn rect_path(&mut self) -> Result<Vec<BezPath>, Flow> {
// x y w h, or an array / string of numbers.
let top = self.pop();
let nums: Vec<f64> = match &top {
V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(),
V::Array(_) | V::Proc(_) => {
let items = top.items().unwrap_or_default();
// An array's rectangles count against the budget, five path elements each.
self.spend(items.len() / 4 * 5)?;
items.iter().filter_map(V::num).collect()
}
V::Num(h) => {
let w = self.popn();
let y = self.popn();
@@ -582,8 +636,10 @@ impl Interp {
}
_ => vec![],
};
self.path_fits(nums.len() / 4 * 5)?;
let m = self.gs.ctm;
nums.as_chunks::<4>()
Ok(nums
.as_chunks::<4>()
.0
.iter()
.map(|r| {
@@ -595,7 +651,7 @@ impl Interp {
p.close_path();
p
})
.collect()
.collect())
}
fn grestore(&mut self) {
@@ -799,10 +855,12 @@ impl Interp {
let c = matches!(self.pop(), V::Bool(true));
self.exec_obj(if c { t } else { f }, src)?;
}
// Each pass of a loop counts against the budget.
"repeat" => {
let p = self.pop();
let k = self.popn().clamp(0.0, 1e6) as usize;
for _ in 0..k {
self.spend(1)?;
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
r => r?,
@@ -816,6 +874,7 @@ impl Interp {
let mut i = self.popn();
let mut guard = 0;
while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 {
self.spend(1)?;
self.push(V::Num(i));
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
@@ -828,6 +887,7 @@ impl Interp {
"loop" => {
let p = self.pop();
for _ in 0..1_000_000 {
self.spend(1)?;
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
r => r?,
@@ -840,6 +900,7 @@ impl Interp {
V::Dict(d) => {
let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in items {
self.spend(1)?;
self.push(V::Lit(k.into()));
self.push(v);
match self.exec_obj(p.clone(), src) {
@@ -851,6 +912,7 @@ impl Interp {
V::Str(s) => {
let bytes = s.borrow().clone();
for c in bytes {
self.spend(1)?;
self.push(V::Num(c as f64));
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
@@ -860,6 +922,7 @@ impl Interp {
}
a => {
for v in a.items().unwrap_or_default() {
self.spend(1)?;
self.push(v);
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
@@ -1117,6 +1180,7 @@ impl Interp {
"languagelevel" => self.push(V::Num(3.0)),
"version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))),
"save" => {
self.spend(self.gs.path.elements().len())?;
self.saved.push(self.gs.clone());
self.gs.depth = self.b.depth();
self.push(V::Null);
@@ -1146,6 +1210,7 @@ impl Interp {
}
// Graphics state.
"gsave" => {
self.spend(self.gs.path.elements().len())?;
let mut g = self.gs.clone();
g.depth = self.b.depth();
self.saved.push(g);
@@ -1306,21 +1371,21 @@ impl Interp {
}
"moveto" => {
let p = self.user_point();
self.move_to(p);
self.move_to(p)?;
}
"rmoveto" => {
let d = self.user_point();
let c = self.current_user();
self.move_to(Point::new(c.x + d.x, c.y + d.y));
self.move_to(Point::new(c.x + d.x, c.y + d.y))?;
}
"lineto" => {
let p = self.user_point();
self.line_to(p);
self.line_to(p)?;
}
"rlineto" => {
let d = self.user_point();
let c = self.current_user();
self.line_to(Point::new(c.x + d.x, c.y + d.y));
self.line_to(Point::new(c.x + d.x, c.y + d.y))?;
}
"curveto" | "rcurveto" => {
let p3 = self.user_point();
@@ -1329,25 +1394,27 @@ impl Interp {
if n == "rcurveto" {
let c = self.current_user();
let o = |p: Point| Point::new(c.x + p.x, c.y + p.y);
self.curve_to(o(p1), o(p2), o(p3));
self.curve_to(o(p1), o(p2), o(p3))?;
} else {
self.curve_to(p1, p2, p3);
self.curve_to(p1, p2, p3)?;
}
}
"closepath" => {
if self.gs.cur.is_some() {
self.room(1)?;
self.gs.path.close_path();
self.gs.cur = self.gs.start;
}
}
"arc" => self.arc(false),
"arcn" => self.arc(true),
"arc" => self.arc(false)?,
"arcn" => self.arc(true)?,
"currentpoint" => {
let c = self.current_user();
self.push(V::Num(c.x));
self.push(V::Num(c.y));
}
"pathbbox" => {
self.spend(self.gs.path.elements().len())?;
let r = kurbo::Shape::bounding_box(&self.gs.path);
let inv = self.gs.ctm.inverse();
let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1));
@@ -1366,13 +1433,14 @@ impl Interp {
"stroke" => self.paint(None, true),
"clip" | "eoclip" => {
let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd };
self.spend(self.gs.path.elements().len())?;
let p = self.gs.path.clone();
if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) {
self.b.clip(p, rule);
}
}
"rectfill" | "rectstroke" | "rectclip" => {
let paths = self.rect_path();
let paths = self.rect_path()?;
if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) {
self.pop();
}
@@ -1607,9 +1675,9 @@ impl Interp {
/// A string's glyph outlines (device space) from the current point, with an extra
/// displacement `extra(index, code)` (user space) after each glyph; moves the current
/// point.
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> {
let d = self.gs.font.clone()?;
let (font, m) = self.font_of(&d)?;
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<Option<(BezPath, String)>, Flow> {
let Some(d) = self.gs.font.clone() else { return Ok(None) };
let Some((font, m)) = self.font_of(&d) else { return Ok(None) };
let mut p = self.current_user();
let mut path = BezPath::new();
let mut text = String::new();
@@ -1624,8 +1692,8 @@ impl Interp {
let (ex, ey) = extra(i, code);
p += adv + kurbo::Vec2::new(ex, ey);
}
self.move_to(p);
Some((path, text))
self.move_to(p)?;
Ok(Some((path, text)))
}
fn fill_text(&mut self, path: BezPath, text: &str) {
@@ -1641,11 +1709,12 @@ impl Interp {
}
}
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) {
match self.text_path(s, extra) {
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> {
match self.text_path(s, extra)? {
Some((path, text)) => self.fill_text(path, &text),
None => self.b.skip("text (no font)"),
}
Ok(())
}
/// Text and font operators (PostScript Language Reference §5.1, §8.2).
@@ -1703,20 +1772,20 @@ impl Interp {
"StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)),
"show" => {
let s = self.pop_string();
self.show_with(&s, |_, _| (0.0, 0.0));
self.show_with(&s, |_, _| (0.0, 0.0))?;
}
"ashow" => {
let s = self.pop_string();
let ay = self.popn();
let ax = self.popn();
self.show_with(&s, |_, _| (ax, ay));
self.show_with(&s, |_, _| (ax, ay))?;
}
"widthshow" => {
let s = self.pop_string();
let ch = self.popn() as u32;
let cy = self.popn();
let cx = self.popn();
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) });
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?;
}
"awidthshow" => {
let s = self.pop_string();
@@ -1725,18 +1794,18 @@ impl Interp {
let ch = self.popn() as u32;
let cy = self.popn();
let cx = self.popn();
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) });
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?;
}
"kshow" | "cshow" => {
// The procedure between characters is not run.
let s = self.pop_string();
self.pop();
self.show_with(&s, |_, _| (0.0, 0.0));
self.show_with(&s, |_, _| (0.0, 0.0))?;
}
"xshow" | "yshow" | "xyshow" => {
let disp: Vec<f64> = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect();
let s = self.pop_string();
self.explicit_show(&s, &disp, n);
self.explicit_show(&s, &disp, n)?;
}
"glyphshow" => {
let name = self.pop().key();
@@ -1747,7 +1816,7 @@ impl Interp {
let tmp = transformed(&d, Affine::IDENTITY);
tmp.borrow_mut().insert("Encoding".into(), array(e));
self.gs.font = Some(tmp);
self.show_with(b"A", |_, _| (0.0, 0.0));
self.show_with(b"A", |_, _| (0.0, 0.0))?;
self.gs.font = Some(d);
}
"charpath" => {
@@ -1755,14 +1824,17 @@ impl Interp {
let s = self.pop_string();
let saved = std::mem::take(&mut self.gs.path);
let start = self.gs.start;
match self.text_path(&s, |_, _| (0.0, 0.0)) {
match self.text_path(&s, |_, _| (0.0, 0.0))? {
Some((glyphs, _)) => {
let mut p = saved;
p.extend(glyphs);
// The outlines join the path: each element counts against the budget and
// the path limit.
self.gs.path = saved;
self.room(glyphs.elements().len() + 1)?;
self.spend(glyphs.elements().len())?;
self.gs.path.extend(glyphs);
if let Some(c) = self.gs.cur {
p.move_to(c);
self.gs.path.move_to(c);
}
self.gs.path = p;
self.gs.start = start;
}
None => self.gs.path = saved,
@@ -1812,7 +1884,7 @@ impl Interp {
/// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the
/// advances.
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) {
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> {
let step = if n == "xyshow" { 2 } else { 1 };
let d: Vec<(f64, f64)> = (0..s.len())
.map(|k| {
@@ -1827,13 +1899,13 @@ impl Interp {
.collect();
let Some(fd) = self.gs.font.clone() else {
self.b.skip("text (no font)");
return;
return Ok(());
};
let Some((font, m)) = self.font_of(&fd) else { return };
let Some((font, m)) = self.font_of(&fd) else { return Ok(()) };
self.show_with(s, move |k, c| {
let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO;
let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0));
(dx - adv.x, dy - adv.y)
});
})
}
}
+94
View File
@@ -396,6 +396,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() {
assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
}
/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at
/// the operation limit.
fn runs_out_of_budget(body: &str) {
let doc = eps_soon(&format!("0 0 10 10 rectfill {body}"));
assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped);
assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}");
}
#[test]
fn paths_made_in_a_loop_run_out_of_the_budget() {
// Each curve, rectangle element and glyph outline element an operator makes counts against the
// operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call,
// `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the
// outlines of 1,000 H's.
for body in [
"{ newpath 0 0 2200000000000000000 0 1440 arc } loop",
"/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop",
"/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \
{ newpath 0 0 moveto s false charpath } loop",
] {
runs_out_of_budget(body);
}
}
#[test]
fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() {
// `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it:
// each element counts against the operation budget. The path has 100,001 elements.
let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for";
for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] {
runs_out_of_budget(&format!("{path} {body}"));
}
}
#[test]
fn nested_loops_run_out_of_the_budget() {
// Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and
// so does each item of a procedure that is not a name. `clear` empties the operand stack that
// the passes fill.
let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000));
for body in [
"{ {} loop } loop",
"{ 1000000 {} repeat } loop",
"{ 0 1 999999 {} for clear } loop",
"/s 1000000 string def { s {} forall clear } loop",
"/a 1000000 array def { a {} forall clear } loop",
"/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop",
literals.as_str(),
] {
runs_out_of_budget(body);
}
}
#[test]
fn paths_stop_at_the_path_limit() {
// A path holds at most 1,048,576 elements. An operator that would add more stops the program:
// the square drawn before it stays, and the square after it is left out.
for body in [
"newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat",
"newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat",
"newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat",
// 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves.
"newpath 0 0 200000000000000000 0 368640 arc",
// At a radius of 10^300, kurbo divides the arc into more curves than a usize can count.
"newpath 0 0 10 300 exp 0 90 arc",
// 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",
// 210,000 rectangles of five elements each.
"/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill",
] {
let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill"));
assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped);
assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}");
}
// A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`),
// and so does the current point that `show` sets, so 550,000 pieces of text placed with
// `moveto` and `show` leave a path of one element.
let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill");
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
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)
// and `h` stop the stream at the limit, and the square painted before it stays.
for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] {
let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap();
assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped);
assert_eq!(doc.skipped, ["content (path limit)"], "{op}");
}
}
#[test]
fn closing_an_empty_path_is_a_no_op() {
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).