Merge pull request #1142 from Biromedic/fix/gradient-alpha-space

Fix #1140: gradient stops blend colour and opacity apart on the canvas, as the exports do
This commit is contained in:
BFlat 2026-10-11 16:02:26 +07:00 committed by GitHub
commit 6daba37d3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 62 additions and 2 deletions

File diff suppressed because one or more lines are too long

View File

@ -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),

View File

@ -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);
}
}
}