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:
parent
e1599a16aa
commit
de3f53b24d
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue