From 1726c600d28874459b255ebc72f25c0827f4f30f Mon Sep 17 00:00:00 2001 From: Biromedic <69905454+Biromedic@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:24:42 +0300 Subject: [PATCH] Fix #1140: gradient stops blend colour and opacity apart on the canvas, as the exports do The canvas built its peniko gradients premultiplied (peniko's default), while Gradient::sample, the PDF export (shading plus SMask) and SVG viewers blend colour and opacity separately: a transparent white stop into black drew grey 127 halfway instead of 191. Opaque gradients are unchanged. --- crates/render/src/paint.rs | 4 +- crates/render/tests/gradient_alpha.rs | 58 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 crates/render/tests/gradient_alpha.rs diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 0f6e41bb..19dea73a 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -43,7 +43,9 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr peniko::Gradient::new_linear(s, e).with_stops(stops.as_slice()) } }; - ctx.set_paint(grad); + // Colour and opacity blend each on its own, as the model samples them and as the PDF + // and SVG exports draw them (peniko's default premultiplies). + ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); true } Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f), diff --git a/crates/render/tests/gradient_alpha.rs b/crates/render/tests/gradient_alpha.rs new file mode 100644 index 00000000..15cf2124 --- /dev/null +++ b/crates/render/tests/gradient_alpha.rs @@ -0,0 +1,58 @@ +//! Gradient stops with opacity: colour and opacity blend each on its own (not premultiplied), as +//! the model samples them ([`Gradient::sample`]) and as the SVG and PDF exports draw them. The colour +//! of a transparent stop counts: red fading into transparent black darkens as it fades. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, render_view}; + +/// A 100 pt square filled with `gradient` on `geom`. +fn filled(gradient: &Gradient, geom: GradientGeom) -> Document { + let mut gp = GradientPaint::new(gradient.clone()); + gp.geom = Some(geom); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0), |_| {}); + b.build() +} + +fn stop(offset: f32, color: Color, opacity: f32, midpoint: f32) -> GradientStop { + GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) } +} + +/// Every 7th pixel is the model's colour at its opacity over white (pixel centres). +fn assert_follows_model(img: &Image, gradient: &Gradient, geom: &GradientGeom) { + for y in (3..100).step_by(7) { + for x in (3..100).step_by(7) { + let t = geom.param_at(gradient.kind, Point::new(x as f64 + 0.5, y as f64 + 0.5)).clamp(0.0, 1.0); + let (c, o) = gradient.sample(t as f32); + let want = c.to_rgb().map(|v| ((v * o + 1.0 - o) * 255.0).round() as u8); + let got = img.over_white(x, y); + let off = (0..3).map(|k| got[k].abs_diff(want[k])).max().unwrap_or(0); + assert!(off <= 4, "{:?} ({x},{y}) t={t:.3}: got {got:?}, want {want:?}", gradient.kind); + } + } +} + +#[test] +fn transparent_stops_blend_colour_and_opacity_apart() { + let red = Color::rgb(1.0, 0.0, 0.0); + let fades = [ + // Transparent white into black: mid-grey at half opacity halfway, light over white. + vec![stop(0.0, Color::WHITE, 0.0, 0.5), stop(1.0, Color::BLACK, 1.0, 0.5)], + // Red into transparent black: darker red as it fades. + vec![stop(0.0, red, 1.0, 0.5), stop(1.0, Color::BLACK, 0.0, 0.5)], + // A midpoint off centre (drawn as an extra stop) follows the same rule. + vec![stop(0.0, red, 1.0, 0.3), stop(1.0, Color::BLACK, 0.0, 0.5)], + ]; + for stops in fades { + for (kind, geom) in [ + (GradientKind::Linear, GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }), + ] { + let gradient = Gradient { kind, stops: stops.clone() }; + assert_follows_model(&render_view(&filled(&gradient, geom), 100, 100), &gradient, &geom); + } + } +}