Gradients: bound the stops sampled per pixel (#2950)

* 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.

* address review: bound tagged gradients to the stop limit
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-10 17:59:59 -05:00
1 parent e1ea71f540
commit 8d46e1df7f
2 files changed
+33 -4

No files matched your search

+21
View File
@@ -165,6 +165,27 @@ 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);
// The tagged (typed) form is bounded the same way.
let tagged = Value::Gradient(Gradient { colors: (0..=MAX_GRADIENT_STOPS).map(|i| (i as f64, [0.0; 4])).collect(), opacities: vec![(0.0, 1.0)] });
assert_eq!(g.coerce_json(&tagged.to_json()), 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]
+12 -4
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| {
@@ -469,9 +476,10 @@ impl Value {
}
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
Value::Path(_) => serde_json::from_value::<ShapePath>(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::<Value>(j.clone()).ok()),
Value::Gradient(_) => {
serde_json::from_value::<Value>(j.clone()).ok().filter(|v| matches!(v, Value::Gradient(_))).or_else(|| gradient_json(j).map(Value::Gradient))
}
Value::Gradient(_) => serde_json::from_value::<Value>(j.clone())
.ok()
.filter(|v| matches!(v, Value::Gradient(g) if g.colors.len() <= MAX_GRADIENT_STOPS && g.opacities.len() <= MAX_GRADIENT_STOPS))
.or_else(|| gradient_json(j).map(Value::Gradient)),
}
}
/// JSON for display/automation (numbers/arrays for numeric kinds).