From efcdad04af771dbf1a9189abef9a2cfdaf820743 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 20:56:23 +0200 Subject: [PATCH] Expressions: reject malformed bezier curves and bound the y error - accept only four-element arrays for bezier(): extra values make the curve malformed, so it falls back to linear like the other invalid forms (#1884); - stop Newton on the estimated y error (f * dy/dx) instead of a residual in x alone: near the endpoints dy/dx is in the thousands, so a 1e-9 residual in x could still move the returned value; - tests: a five-element curve is linear, and three steep curves match a bisection reference within 1e-6. --- crates/expr/src/prelude.js | 7 +++++-- crates/expr/src/tests.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index c69cd9a67..589dde55c 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -213,7 +213,7 @@ function easeOut() { return __interp(__easeOut, arguments); } // finite numbers is linear, never an error. function __cubicBezier(curve) { var x1, y1, x2, y2; - if (Array.isArray(curve) && curve.length >= 4) { + if (Array.isArray(curve) && curve.length === 4) { x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; } if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || @@ -225,15 +225,18 @@ function __cubicBezier(curve) { function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); } return function (x) { if (x <= 0) return 0; if (x >= 1) return 1; var u = x; for (var i = 0; i < 8; i++) { var f = bx(u) - x; - if (Math.abs(f) < 1e-9) return by(u); var d = dbx(u); if (Math.abs(d) < 1e-12) break; + // Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x + // alone can still mean a visible error in y (f * dy/dx estimates it). + if (Math.abs(f * dby(u) / d) < 1e-9) return by(u); var n = u - f / d; if (!(n >= 0 && n <= 1)) break; u = n; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 27b374078..b651802f8 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -343,13 +343,42 @@ fn cubic_bezier_short_form_arrays_and_fallbacks() { // A curve that is not four finite numbers falls back to linear instead of throwing. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); // x control points outside 0..1 are clamped, as CSS requires. let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); assert!(v.is_finite(), "clamped curve: {v}"); } -// ---- object model ----------------------------------------------------------------------------- +#[test] +fn cubic_bezier_is_accurate_near_the_endpoints() { + /// The value the curve reaches at `x`, by bisection on its monotone x(u). + fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; + let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if bx(mid) < x { + lo = mid; + } else { + hi = mid; + } + } + by(0.5 * (lo + hi)) + } + // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the + // thousands), so the solver stops on the estimated y error instead. + let f = fx(); + for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { + let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); + let want = reference(c[0], c[1], c[2], c[3], x); + assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); + } +} +// ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx();