From 069852286b5af26b135ce731caee6c02720c4653 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 14:40:41 -0400 Subject: [PATCH] Render: two-colour gradients ramp like PowerPoint's PowerPoint blends a gradient of exactly two stops at 0% and 100% in linear light, with the second colour's share following an S-shaped curve (black to white is 105/186/238 at a quarter/half/three quarters, not 64/128/191). Gradients with more stops, or a stop moved off an end, stay linear in sRGB. Measured with PowerPoint's slide export on shapes and backgrounds, in both directions. The two-colour case is expanded into sixteen pieces per segment so vello's sRGB interpolation draws the same ramp. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 1 + crates/render/src/paint.rs | 7 +- crates/render/src/ramp.rs | 128 +++++++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 49 ++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/ramp.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577..ec77205 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod morph_path; mod morph_text; mod paint; mod placed; +mod ramp; mod table; use std::collections::HashMap; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1dcbcd8..2181f8e 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -35,10 +35,11 @@ pub fn fill_path( if g.stops.is_empty() { return; } - let mut stops: Vec<(f32, peniko::Color)> = - g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect(); + let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect(); stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - let stops: Vec = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect(); + // PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`. + let stops: Vec = + crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); let grad = match &g.shape { GradientShape::Linear { angle, .. } => { // Angle measured clockwise from left→right; span the bounds along that direction. diff --git a/crates/render/src/ramp.rs b/crates/render/src/ramp.rs new file mode 100644 index 0000000..97c6c9c --- /dev/null +++ b/crates/render/src/ramp.rs @@ -0,0 +1,128 @@ +//! Gradient colour ramps as PowerPoint draws them. +//! +//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not +//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second +//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a +//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any +//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a +//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export). +//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short +//! pieces whose colours follow PowerPoint's ramp. + +use deckcraft_color::Rgba; + +/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop, +/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`. +const WEIGHT: [f64; 17] = + [0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0]; + +/// Pieces each segment between two stops is cut into. +const PIECES: usize = 16; + +/// The next stop's share at `t` (0..1) between two stops. +pub fn weight(t: f64) -> f64 { + if !t.is_finite() { + return 0.0; + } + let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64; + let i = (x.floor() as usize).min(WEIGHT.len() - 2); + let f = x - i as f64; + let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0)); + a + (b - a) * f +} + +fn to_linear(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +fn to_srgb(v: f64) -> u8 { + let v = v.clamp(0.0, 1.0); + let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + (s * 255.0).round().clamp(0.0, 255.0) as u8 +} + +/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share. +pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba { + let w = weight(t); + let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w); + Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 } +} + +/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get +/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp, +/// anything else unchanged. +pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> { + let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6); + if !two_colour { + return stops.to_vec(); + } + let mut out = Vec::with_capacity(stops.len() * PIECES + 1); + for (i, &(p, c)) in stops.iter().enumerate() { + out.push((p, c)); + let Some(&(q, d)) = stops.get(i + 1) else { continue }; + if q - p <= 1e-6 || c == d { + continue; + } + for k in 1..PIECES { + let t = k as f64 / PIECES as f64; + out.push((p + (q - p) * t, mix(c, d, t))); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn black_to_white_follows_powerpoint() { + // PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters. + let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255)); + for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] { + let got = mix(k, w, t).r as i32; + assert!((got - want).abs() <= 3, "{t}: {got} vs {want}"); + } + } + + #[test] + fn colour_pairs_follow_powerpoint() { + // Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146). + let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5); + assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}"); + // Red to blue a quarter of the way: (238, 0, 105). + let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25); + assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}"); + } + + #[test] + fn the_ends_are_the_stops_and_the_weight_is_symmetric() { + let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90)); + assert_eq!(mix(a, b, 0.0), a); + assert_eq!(mix(a, b, 1.0), b); + for k in 0..=32 { + let t = k as f64 / 32.0; + assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9); + assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic"); + } + assert_eq!(weight(f64::NAN), 0.0); + } + + #[test] + fn expand_keeps_stops_and_hard_edges() { + let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0)); + // Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged. + let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]); + assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]); + assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]); + assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3); + // Two equal colours add nothing. + assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]); + let e = expand(&[(0.0, r), (1.0, g)]); + assert_eq!(e.len(), PIECES + 1); + assert_eq!(e.first(), Some(&(0.0, r))); + assert_eq!(e.last(), Some(&(1.0, g))); + assert!(e.windows(2).all(|w| w[0].0 <= w[1].0)); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472..9b7fa01 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -523,3 +523,52 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() })); assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0); } + +#[test] +fn a_linear_gradient_ramps_like_powerpoint() { + // Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way + // (linear-light mix, S-shaped), not 64 and 128. + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) }; + let sh = Shape { + id: ShapeId(700), + xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)), + fill: Some(Fill::Gradient(Gradient { + stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + })), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + for (x, want) in [(200, 105), (400, 186), (600, 238)] { + let got = img.pixel(x, 50)[0] as i32; + assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}"); + } +} + +#[test] +fn a_gradient_with_a_stop_off_the_end_stays_linear() { + // Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %). + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) }; + let sh = Shape { + id: ShapeId(701), + xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)), + fill: Some(Fill::Gradient(Gradient { + stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + })), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + for (x, want) in [(400, 96), (600, 175)] { + let got = img.pixel(x, 50)[0] as i32; + assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}"); + } +}