EPS: bound the stitched-gradient builder

Cap the stops taken from a stitching function (1024), bound linear_points by
depth and by a shared visit count, and stop eval from copying the whole
Functions array on every call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ikki Nishihara 2026-10-10 17:29:44 +09:00
parent e1599a16aa
commit de3f53b24d
2 changed files with 130 additions and 9 deletions

View File

@ -34,6 +34,9 @@ const MAX_PATH: usize = 1 << 20;
const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
const SHADING_SAMPLES: usize = 32;
/// Most bounds of a stitched linear function that become a gradient's stops: past it, the
/// function is sampled like any other (each stop evaluates the whole function).
const MAX_STITCH_STOPS: usize = 1024;
pub(super) const FAR_AWAY: &str = "objects far outside the page were left out";
pub(super) const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out";
@ -1007,8 +1010,10 @@ impl Interp<'_> {
let domain = nums(get("Domain"));
let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?;
// Where the colour changes: the ends, and the bounds of stitched linear functions.
let ts: Vec<f64> = match linear_points(&f) {
// Where the colour changes: the ends, and the bounds of stitched linear functions (unless
// there are too many).
let mut visits = 0;
let ts: Vec<f64> = match linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS) {
Some(mut pts) => {
pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t));
pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect()
@ -1085,16 +1090,21 @@ impl Interp<'_> {
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
}
Some(3.0) => {
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
// Read the pieces through the borrow and clone only the one we recurse into: a
// `Functions` array of millions (shared, or reached again through a cycle) must
// not be copied on every call and at every live recursion level.
let functions = get("Functions");
let fs = functions.as_ref().and_then(Obj::items);
let n = fs.map_or(0, Shared::len);
let bounds = nums(get("Bounds"));
let encode = nums(get("Encode"));
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
let k = bounds.iter().take_while(|b| t >= **b).count().min(fs.len().saturating_sub(1));
let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1));
let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) };
let b = bounds.get(k).copied().unwrap_or(hi);
let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0));
let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 };
let g = fs.get(k).cloned().ok_or(PsError::Ps("rangecheck", "Functions".into()))?;
let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?;
self.eval(&g, &[u], depth + 1)
}
_ => {
@ -1621,16 +1631,34 @@ fn is_mesh(sh: &DictRef) -> bool {
}
/// Where a stitched function of linear interpolations changes slope (its bounds), when it is
/// one; `None` for functions that must be sampled.
fn linear_points(f: &Obj) -> Option<Vec<f64>> {
/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way
/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on
/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a
/// function that refers to itself in a cycle, but not one that fans out through shared children
/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A
/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it
/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off.
fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option<Vec<f64>> {
*visits += 1;
if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) {
return None;
}
let Obj::Dict(d) = f else { return None };
let d = d.borrow();
let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num);
match num("FunctionType")? {
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
3.0 => {
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
let items = d.get(&Key::name("Functions"))?.items()?;
// More pieces than the stop cap allows: sample the function instead (tested before
// `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop
// runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge
// `Functions` would still be O(stops * Functions) to find each piece without this.
if items.len() > MAX_STITCH_STOPS + 1 {
return None;
}
let fs = items.to_vec();
if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) {
return None;
}
d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())

View File

@ -897,6 +897,99 @@ fn hostile_patterns_glyphs_and_shadings_end() {
assert_eq!(objects(&r.document).len(), 1);
}
/// The stops of the gradient `body` draws, after asserting it came in cleanly as one gradient.
fn stitch_stops(body: &str) -> Vec<f32> {
let r = read(body);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let o = objects(&r.document);
let [node] = o.as_slice() else { panic!("{o:?}") };
let Some(Paint::Gradient(g)) = fill(node) else { panic!("{node:?}") };
g.gradient.stops.iter().map(|s| s.offset).collect()
}
/// A gradient stitched from linear pieces keeps their bounds as its stops up to the cap, and is
/// sampled past it. The cap is on the pieces; a well-formed stitch has one more piece than bounds.
#[test]
fn gradients_stitched_from_linear_pieces_keep_or_sample_their_stops() {
// `bounds` interior bounds at i / (bounds + 1), with one linear piece more than that.
let stitched = |bounds: usize| {
let pieces = bounds + 1;
format!(
"/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \
/Functions [ {pieces} {{ F }} repeat ] /Bounds [ 1 1 {bounds} {{ {pieces} div }} for ] >> >> shfill"
)
};
// A few pieces: the bounds are kept exactly as stops (three bounds -> five stops with the ends).
assert_eq!(stitch_stops(&stitched(3)), vec![0.0, 0.25, 0.5, 0.75, 1.0]);
// At the cap (1024 bounds, 1025 pieces) the 1024 bounds and the two ends are all kept.
assert_eq!(stitch_stops(&stitched(1024)).len(), 1026);
// One past it (1025 bounds, 1026 pieces) the function is sampled instead.
assert_eq!(stitch_stops(&stitched(1025)).len(), 33);
}
/// A malformed stitching function with a single piece but thousands of bounds is sampled, not
/// turned into thousands of stops. This is the one case the stop-count `.filter` catches on its
/// own: the one piece is under the Functions-length guard, so only the bound count is out of range.
#[test]
fn a_stitching_function_with_one_piece_but_many_bounds_is_sampled() {
let stops = stitch_stops(
"/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \
/Functions [ F ] /Bounds [ 1 1 2000 { 2001 div } for ] >> >> shfill",
);
// Base keeps all 2000 bounds (2002 stops); the fix samples.
assert_eq!(stops.len(), 33);
}
/// A short `Bounds` with a `Functions` array one past the cap is sampled: without the length
/// guard each of the ~1000 stops would read through the whole array to find its piece.
#[test]
fn a_gradient_with_a_functions_array_past_the_cap_is_sampled() {
let stops = stitch_stops(
"/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \
/Functions [ 1026 { F } repeat ] /Bounds [ 1 1 1000 { 1001 div } for ] >> >> shfill",
);
// Base keeps the 1000 bounds (1002 stops); the fix samples.
assert_eq!(stops.len(), 33);
}
/// A stitching function (type 3) whose `Functions` array holds the function itself must not
/// recurse without end: `linear_points` (depth- and visit-bounded) and `eval` (depth-bounded)
/// both stop, so the shading raises an error instead of overflowing the stack (an abort
/// `vectorcraft_engine::guard` can't catch, and on wasm there is no net at all).
#[test]
fn a_stitching_function_that_refers_to_itself_is_rejected_not_crashed() {
let r = read(
"0 0 10 10 rectfill \
/F 5 dict def F /FunctionType 3 put F /Domain [0 1] put F /Bounds [] put F /Encode [0 1] put F /Functions [ F ] put \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function F >> shfill",
);
// Not an abort: the reader stops at the error, keeps the rectangle drawn before it, and leaves
// the rest of the program unread (see `import`: what follows the error is left out).
assert_eq!(objects(&r.document).len(), 1);
assert!(r.warnings.iter().any(|w| w.contains("read up to an error") && w.contains("Function")), "{:?}", r.warnings);
}
/// A stitching function need not point at itself to blow up: a `Functions` array of 1025
/// references to a child that holds 1025 references to a child ... is a tree of 1025^levels nodes
/// though it is tiny in memory (the children are shared). The shared visit counter stops
/// `linear_points` after about 2 * (MAX_STITCH_STOPS + 1) nodes, so it is sampled at once instead
/// of being walked; on the unmodified reader this explores ~1025^3 nodes.
#[test]
fn a_deeply_shared_stitching_function_is_sampled_not_walked() {
// leaf (linear) <- mid2[1025 leaf] <- mid1[1025 mid2] <- top[1025 mid1] (1000 bounds).
let stops = stitch_stops(
"/leaf << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
/mid2 << /FunctionType 3 /Domain [0 1] /Bounds [] /Functions [ 1025 { leaf } repeat ] >> def \
/mid1 << /FunctionType 3 /Domain [0 1] /Bounds [] /Functions [ 1025 { mid2 } repeat ] >> def \
/top << /FunctionType 3 /Domain [0 1] /Bounds [ 1 1 1000 { 1001 div } for ] /Functions [ 1025 { mid1 } repeat ] >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function top >> shfill",
);
assert_eq!(stops.len(), 33);
}
/// Executable strings and keys other than names (#474): `cvx` makes a string executable, and
/// running it runs its text (PLRM 3rd ed. §3.3.1, `cvx`, `exec`); `load` takes any key (§8.2).
/// A program closes its shading dictionaries with an executable string (`>>` at Level 2 and up,