Gradients: bound the stops sampled per pixel

Gradient sampling scans the stops linearly and shape painting samples once
per pixel, so a gradient with a huge stop list multiplied every frame's
work. Sampling now looks at the first 256 colour and opacity stops, and the
plain JSON form (coerce_json) refuses gradients with more than 256.
This commit is contained in:
Dylan M. Taylor
2026-11-02 22:15:52 -05:00
parent 7870b5916a
commit 0beb8fd6c7
2 changed files with 26 additions and 1 deletions
+18
View File
@@ -130,6 +130,24 @@ fn gradient_tracks_sample_separately() {
assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0));
}
/// A gradient's stop count multiplies every painted pixel's sampling: plain JSON with more than
/// `MAX_GRADIENT_STOPS` is refused, and a longer stored list is sampled through its first ones.
#[test]
fn gradient_stop_count_is_bounded() {
use crate::value::MAX_GRADIENT_STOPS;
let g = Value::Gradient(Gradient::default());
let many: Vec<serde_json::Value> = (0..=MAX_GRADIENT_STOPS).map(|_| serde_json::json!("#ff0000")).collect();
assert_eq!(g.coerce_json(&serde_json::Value::Array(many.clone())), None);
assert!(g.coerce_json(&serde_json::Value::Array(many[..MAX_GRADIENT_STOPS].to_vec())).is_some());
let ops: Vec<serde_json::Value> = (0..=MAX_GRADIENT_STOPS).map(|i| serde_json::json!([i as f64 / MAX_GRADIENT_STOPS as f64, 1.0])).collect();
assert_eq!(g.coerce_json(&serde_json::json!({"colors": ["#000000", "#ffffff"], "opacities": ops})), None);
// A stored gradient with a huge tail: stops past the limit don't take part in sampling.
let mut colors: Vec<(f64, [f32; 4])> = (0..MAX_GRADIENT_STOPS).map(|i| (i as f64 / MAX_GRADIENT_STOPS as f64 * 0.5, [0.0, 0.0, 0.0, 1.0])).collect();
colors.extend((0..100_000).map(|_| (1.0, [1.0, 1.0, 1.0, 1.0])));
let big = Gradient { colors, opacities: vec![(0.0, 1.0)] };
assert_eq!(big.sample(1.0), [0.0, 0.0, 0.0, 1.0]);
}
/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser
/// rejects) and each stop's RGBA come back exactly.
#[test]
+8 -1
View File
@@ -210,7 +210,13 @@ impl Gradient {
}
}
/// Most stops (colour or opacity) a gradient is sampled through. Gradients are sampled per
/// pixel, so the stop count multiplies every painted pixel's work: stops past this are ignored
/// when sampling, and a gradient written as JSON with more is refused.
pub const MAX_GRADIENT_STOPS: usize = 256;
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
let stops = &stops[..stops.len().min(MAX_GRADIENT_STOPS)];
let first = stops.first()?;
if t <= first.0 {
return Some(first.1);
@@ -243,7 +249,7 @@ fn gradient_json(j: &serde_json::Value) -> Option<Gradient> {
}
fn stops(a: &[J]) -> Option<Vec<(f64, [f32; 4])>> {
let n = a.len();
if n < 2 {
if !(2..=MAX_GRADIENT_STOPS).contains(&n) {
return None;
}
a.iter()
@@ -259,6 +265,7 @@ fn gradient_json(j: &serde_json::Value) -> Option<Gradient> {
J::Object(o) => {
let colors = stops(o.get("colors")?.as_array()?)?;
let op = match o.get("opacities") {
Some(J::Array(a)) if a.len() > MAX_GRADIENT_STOPS => return None,
Some(J::Array(a)) => Some(
a.iter()
.map(|s| {