Fix Vello rendering incompatibility with its gradients using premultiplied alpha instead of SVG's straight (#4419)

* Upgrade Vello to a git main revision that honors the brush's interpolation alpha space

* Interpolate Vello gradients with straight alpha, matching the SVG renderer

* Draw the gradient picker strip and fill swatches as SVG so transparency previews with straight alpha

* Keep gradient stop handles opaque so a transparent stop's RGB stays visible

* Paint a stopless gradient's picker strip and swatch black rather than transparent
This commit is contained in:
Keavon Chambers
2026-08-07 18:45:12 -07:00
committed by GitHub
parent 681b4033f5
commit 6b1e9912ef
9 changed files with 172 additions and 69 deletions

View File

@@ -538,7 +538,8 @@ fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_trans
},
extend: peniko_extend(settings.spread),
stops: peniko_stops,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
// Straight alpha, keeping parity with the SVG renderer's stop interpolation
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied,
..Default::default()
});
@@ -2315,7 +2316,7 @@ impl Render for List<Gradient> {
kind,
stops,
extend,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied,
..Default::default()
});
let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array());

View File

@@ -91,9 +91,9 @@ impl From<&GradientStops<SRGBA8>> for Gradient {
}
impl GradientStops<SRGBA8> {
/// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes).
pub fn to_css_linear_gradient(&self, settings: GradientSettings) -> String {
Gradient::from(self).to_css_linear_gradient(settings)
/// CSS `background-image` value drawing the stops as an SVG data URI, keeping straight-alpha interpolation.
pub fn to_svg_background_image(&self, settings: GradientSettings) -> String {
Gradient::from(self).to_svg_background_image(settings)
}
}
@@ -1295,22 +1295,33 @@ impl Gradient {
mapped
}
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves and color space so the rendered gradient matches Graphite's interpolation rather than browser defaults.
pub fn to_css_linear_gradient(&self, settings: GradientSettings) -> String {
if self.len() <= 1 {
let hex = self.color(0).map(|c| SRGBA8::from(c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
/// The gradient's [`Gradient::interpolated_samples`], falling back to one black sample when it has no stops, since a
/// stopless SVG gradient paints nothing where [`Gradient::evaluate`] gives black.
pub fn interpolated_samples_or_black(&self, settings: GradientSettings) -> Vec<(f64, Color, Option<f64>)> {
let samples = self.interpolated_samples(settings);
if samples.is_empty() { vec![(0., Color::BLACK, None)] } else { samples }
}
/// Build a CSS `background-image` value embedding the gradient as an SVG data URI, sampling the midpoint curves, color
/// space, and spline. SVG interpolates its stops with straight alpha, matching the canvas renderers, where a CSS
/// `linear-gradient` interpolates premultiplied and would hide the pull a transparent stop's RGB exerts on the render.
pub fn to_svg_background_image(&self, settings: GradientSettings) -> String {
use std::fmt::Write;
let mut stops = String::new();
for (position, color, _) in self.interpolated_samples_or_black(settings) {
let srgba = SRGBA8::from(color);
let _ = write!(stops, "<stop offset='{}' stop-color='#{}'", (position * 1e4).round() / 1e4, srgba.to_rgb_hex());
if srgba.alpha < 255 {
let _ = write!(stops, " stop-opacity='{}'", (color.a() as f64 * 1000.).round() / 1000.);
}
stops.push_str("/>");
}
let pieces = self
.interpolated_samples(settings)
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
format!("#{} {percent}%", SRGBA8::from(color).to_rgba_hex())
})
.collect::<Vec<_>>()
.join(", ");
format!("linear-gradient(to right, {pieces})")
// A sizeless SVG stretches to fill the CSS background area; the encoding covers the URI-hostile characters
let svg = format!("<svg xmlns='http://www.w3.org/2000/svg'><linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>{stops}</linearGradient><rect width='100%' height='100%' fill='url(#g)'/></svg>");
let encoded = svg.replace('%', "%25").replace('#', "%23").replace('<', "%3C").replace('>', "%3E");
format!("url(\"data:image/svg+xml,{encoded}\")")
}
/// Produce a set of linearly-interpolated color samples that approximate the gradient's true curve.
@@ -2193,6 +2204,26 @@ mod tests {
}
}
#[test]
fn svg_background_image_percent_encodes_and_keeps_straight_alpha_stops() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
gradient.set_color(1, Color::from_rgbaf32_unchecked(1., 1., 1., 0.5));
let image = gradient.to_svg_background_image(GradientSettings::default());
assert!(image.starts_with("url(\"data:image/svg+xml,"), "the value should be an SVG data URI: {image}");
assert!(image.contains("stop-opacity='0.5'"), "a transparent stop should emit its straight alpha: {image}");
assert!(!image.contains(['#', '<', '>']), "URI-hostile characters should be percent-encoded: {image}");
}
#[test]
fn svg_background_image_paints_a_stopless_gradient_black() {
let image = Gradient::from(Vec::new()).to_svg_background_image(GradientSettings::default());
// The hex color's `#` arrives percent-encoded
assert!(image.contains("stop-color='%23000000'"), "a gradient with no stops should paint black rather than nothing: {image}");
}
#[test]
fn clear_spread_evaluates_to_transparency_outside_the_unit_range() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);

View File

@@ -67,7 +67,7 @@ impl<C> FillChoice<C> {
}
impl FillChoice<SRGBA8> {
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoice::None`].
/// Build a CSS `background-image` string representing this fill, or `None` if the fill is [`FillChoice::None`].
/// Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
pub fn to_css_background_image(&self) -> Option<String> {
match self {
@@ -76,7 +76,7 @@ impl FillChoice<SRGBA8> {
let hex = srgba.to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient(ramp.into())),
Self::Gradient(ramp) => Some(ramp.stops.to_svg_background_image(ramp.into())),
}
}
}