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 Dennis Kobert
parent 9181a62e99
commit da3ffa1721
9 changed files with 173 additions and 69 deletions

63
Cargo.lock generated
View File

@@ -959,7 +959,7 @@ dependencies = [
"rustc-hash 2.1.1",
"serde",
"serde_json",
"skrifa",
"skrifa 0.42.1",
"tinyvec",
"tokio",
"tsify",
@@ -1669,6 +1669,15 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
@@ -1702,7 +1711,7 @@ dependencies = [
"linebender_resource_handle",
"memmap2 0.9.10",
"parlance",
"read-fonts",
"read-fonts 0.39.2",
"smallvec",
]
@@ -2522,7 +2531,7 @@ dependencies = [
"bitflags 2.11.0",
"bytemuck",
"core_maths",
"read-fonts",
"read-fonts 0.39.2",
"smallvec",
]
@@ -3985,9 +3994,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@@ -4114,7 +4123,7 @@ dependencies = [
"linebender_resource_handle",
"parlance",
"parley_data",
"skrifa",
"skrifa 0.42.1",
]
[[package]]
@@ -4770,7 +4779,18 @@ checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
"core_maths",
"font-types",
"font-types 0.11.3",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types 0.12.2",
"once_cell",
]
[[package]]
@@ -4867,7 +4887,7 @@ dependencies = [
"num-traits",
"parley",
"serde",
"skrifa",
"skrifa 0.42.1",
"text-nodes",
"usvg",
"vector-types",
@@ -5536,7 +5556,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
"read-fonts",
"read-fonts 0.39.2",
]
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts 0.41.0",
]
[[package]]
@@ -5928,7 +5958,7 @@ dependencies = [
"raster-types",
"serde",
"serde_json",
"skrifa",
"skrifa 0.42.1",
"titlecase",
"tsify",
"unicode-segmentation",
@@ -6625,15 +6655,14 @@ dependencies = [
[[package]]
name = "vello"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261359dbef879f8110ef7e1c442246c838d33d3d91cb05e0ea9288d432760c9f"
source = "git+https://github.com/linebender/vello.git?rev=7073a85d61ee099d1d3595651ddf92302b074348#7073a85d61ee099d1d3595651ddf92302b074348"
dependencies = [
"bytemuck",
"futures-intrusive",
"log",
"peniko",
"png 0.18.1",
"skrifa",
"skrifa 0.44.0",
"static_assertions",
"thiserror 2.0.18",
"vello_encoding",
@@ -6644,21 +6673,19 @@ dependencies = [
[[package]]
name = "vello_encoding"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2346f5f0d7dccb3582fcd397b4a57b43165209f1424e0d76e85dd814db164af7"
source = "git+https://github.com/linebender/vello.git?rev=7073a85d61ee099d1d3595651ddf92302b074348#7073a85d61ee099d1d3595651ddf92302b074348"
dependencies = [
"bytemuck",
"guillotiere",
"peniko",
"skrifa",
"skrifa 0.44.0",
"smallvec",
]
[[package]]
name = "vello_shaders"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd38937516fa4b47423d9255bb5e4a65e839ec9d57c38c4af6189ce56bf46b7"
source = "git+https://github.com/linebender/vello.git?rev=7073a85d61ee099d1d3595651ddf92302b074348#7073a85d61ee099d1d3595651ddf92302b074348"
dependencies = [
"bytemuck",
"log",

View File

@@ -166,8 +166,9 @@ url = "2.5"
tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt", "rt-multi-thread"] }
# Linebender ecosystem (BEGIN)
kurbo = { version = "0.13", features = ["serde"] }
vello = "0.9"
vello_encoding = "0.9"
# TODO: Return to crates.io versions once a release after 0.9 ships the ramp bake's `interpolation_alpha_space` support
vello = { git = "https://github.com/linebender/vello.git", rev = "7073a85d61ee099d1d3595651ddf92302b074348" }
vello_encoding = { git = "https://github.com/linebender/vello.git", rev = "7073a85d61ee099d1d3595651ddf92302b074348" }
resvg = "0.47"
usvg = "0.47"
simplecss = "0.2"

View File

@@ -539,9 +539,14 @@ fn populate_computed_display_fields(layout: &mut Layout) {
hue_direction: spectrum_input.track_hue_direction,
interpolation: spectrum_input.track_interpolation,
};
spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient(settings);
let track_gradient = graphene_std::vector::style::Gradient::from(&spectrum_input.track);
spectrum_input.track_samples = track_gradient
.interpolated_samples_or_black(settings)
.into_iter()
.map(|(position, color, _)| SpectrumSample::new(position, color))
.collect();
// The end caps sample the track's boundary colors, which a cyclic wrap makes the wrapped interval's boundary-crossing color rather than the outermost stops'
let track_evaluator = graphene_std::vector::style::Gradient::from(&spectrum_input.track).evaluator(settings);
let track_evaluator = track_gradient.evaluator(settings);
let cap = |t: f64| {
let color = track_evaluator.evaluate(t);
SRGBA8::from(color).to_css_hex()

View File

@@ -586,22 +586,22 @@ pub struct SpectrumInput {
/// The colored gradient drawn behind the markers (display-only, caller-owned).
#[widget_builder(constructor)]
pub track: GradientStops<SRGBA8>,
/// The color space the track's stops interpolate in, used to compute `track_css`. Not sent to the frontend.
/// The color space the track's stops interpolate in, used to bake `track_samples`. Not sent to the frontend.
#[serde(skip)]
pub track_space: GradientSpace,
/// Whether the track's stops wrap as a cycle, used to compute `track_css` and by the frontend to draw the wrapped interval's midpoint diamond.
/// Whether the track's stops wrap as a cycle, used to bake `track_samples` and by the frontend to draw the wrapped interval's midpoint diamond.
#[serde(rename = "trackCyclic")]
pub track_cyclic: bool,
/// The hue direction the track's stops interpolate with in a polar space, used to compute `track_css`. Not sent to the frontend.
/// The hue direction the track's stops interpolate with in a polar space, used to bake `track_samples`. Not sent to the frontend.
#[serde(skip)]
pub track_hue_direction: GradientHueDirection,
/// The path the track's stops interpolate along, used to compute `track_css` and by the frontend to suppress the midpoint diamonds when stepped.
/// The path the track's stops interpolate along, used to bake `track_samples` and by the frontend to suppress the midpoint diamonds when stepped.
#[serde(rename = "trackInterpolation")]
pub track_interpolation: GradientInterpolation,
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time.
#[serde(rename = "trackCSS")]
/// Straight-alpha samples the frontend draws as the stops of an SVG gradient filling the track strip. Auto-populated from `track` at layout-send time.
#[serde(rename = "trackSamples")]
#[widget_builder(skip)]
pub track_css: String,
pub track_samples: Vec<SpectrumSample>,
/// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated by evaluating `track` at position 0.
#[serde(rename = "trackStartCSS")]
#[widget_builder(skip)]
@@ -650,18 +650,40 @@ pub struct SpectrumMarker {
/// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true.
/// The last marker's value controls the wrapped interval when `track_cyclic` is set, and is otherwise ignored.
midpoint: f64,
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`].
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`],
/// discarding any transparency so the handle always shows the RGB that steers the interpolation.
#[serde(rename = "handleColorCSS")]
handle_color_css: String,
}
impl SpectrumMarker {
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self {
let handle_color_css = SRGBA8::from(handle_color).to_css_hex();
let handle_color_css = format!("#{}", SRGBA8::from(handle_color).to_rgb_hex());
Self { position, midpoint, handle_color_css }
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SpectrumSample {
/// Position (0..1) of the sample along the spectrum track, drawn as the SVG stop's `offset`.
position: f64,
/// `#rrggbb` hex of the sample's color, drawn as the SVG stop's `stop-color`.
color: String,
/// Straight alpha (0..1) of the sample, drawn as the SVG stop's `stop-opacity`.
alpha: f32,
}
impl SpectrumSample {
pub fn new(position: f64, color: Color) -> Self {
Self {
position,
color: format!("#{}", SRGBA8::from(color).to_rgb_hex()),
alpha: color.a(),
}
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SpectrumInputUpdate {

View File

@@ -963,12 +963,12 @@ impl Fsm for GradientToolFsmState {
let (start, end) = (unit_to_viewport.transform_point2(DVec2::ZERO), unit_to_viewport.transform_point2(DVec2::X));
fn color_to_hex(color: graphene_std::Color) -> String {
SRGBA8::from(color).to_css_hex()
fn color_to_opaque_hex(color: graphene_std::Color) -> String {
format!("#{}", SRGBA8::from(color).to_rgb_hex())
}
let start_hex = gradient.color(0).map(color_to_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
let end_hex = gradient.color(gradient.len().saturating_sub(1)).map(color_to_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
let start_hex = gradient.color(0).map(color_to_opaque_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
let end_hex = gradient.color(gradient.len().saturating_sub(1)).map(color_to_opaque_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
// Check if the first/last stops are at position ~0/~1 (rendered as the endpoint dots rather than as separate stops)
let settings = appearance.settings;
@@ -1027,7 +1027,7 @@ impl Fsm for GradientToolFsmState {
StopId::End => overlay_context.gradient_color_stop(end, emphasis, &end_hex, !last_at_end),
StopId::Middle(i) => {
if let Some(color) = gradient.color(i) {
overlay_context.gradient_color_stop(start.lerp(end, gradient.position(i, gradient_cyclic)), emphasis, &color_to_hex(color), false);
overlay_context.gradient_color_stop(start.lerp(end, gradient.position(i, gradient_cyclic)), emphasis, &color_to_opaque_hex(color), false);
}
}
};

View File

@@ -3,14 +3,17 @@
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import type { GradientInterpolation, SpectrumInputUpdate, SpectrumMarker } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { GradientInterpolation, SpectrumInputUpdate, SpectrumMarker, SpectrumSample } from "/wrapper/pkg/graphite_wasm_wrapper";
const BUTTON_LEFT = 0;
const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ update: SpectrumInputUpdate; dragging: boolean }>();
export let trackCSS: string;
// Document-unique `id` for this instance's SVG gradient, referenced by its `url(#...)`
const gradientId = `spectrum-input-gradient-${String(Math.random()).substring(2)}`;
export let trackSamples: SpectrumSample[];
export let trackStartCSS: string;
export let trackEndCSS: string;
export let trackCyclic = false;
@@ -396,10 +399,19 @@
styles={{
"--gradient-start": trackStartCSS,
"--gradient-end": trackEndCSS,
"--gradient-stops": trackCSS,
}}
>
<LayoutRow class="gradient-strip" on:pointerdown={trackPointerDown}></LayoutRow>
<LayoutRow class="gradient-strip" on:pointerdown={trackPointerDown}>
<!-- An SVG gradient interpolates its stops with straight alpha, matching the renderers, where a CSS gradient would premultiply -->
<svg class="strip-gradient" xmlns="http://www.w3.org/2000/svg">
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
{#each trackSamples as sample}
<stop offset={sample.position} stop-color={sample.color} stop-opacity={sample.alpha} />
{/each}
</linearGradient>
<rect width="100%" height="100%" fill={`url(#${gradientId})`} />
</svg>
</LayoutRow>
<LayoutRow class="midpoint-track">
{#each midpointPositions as midpoint, index}
<svg
@@ -449,26 +461,30 @@
.gradient-strip {
flex: 0 0 auto;
position: relative;
height: 16px;
background-image:
var(--gradient-stops),
// Solid start/end colors on either side so the gradient begins at the center of a marker
linear-gradient(var(--gradient-start), var(--gradient-start)),
linear-gradient(var(--gradient-end), var(--gradient-end)),
var(--color-transparent-checkered-background);
linear-gradient(var(--gradient-start), var(--gradient-start)), linear-gradient(var(--gradient-end), var(--gradient-end)), var(--color-transparent-checkered-background);
background-size:
calc(100% - 2 * var(--marker-half-width)) 100%,
// TODO: Find a solution that avoids visual artifacts where these end colors meet the gradient that appear when viewing with a non-integer zoom or display scaling factor
var(--marker-half-width) 100%,
var(--marker-half-width) 100%,
var(--color-transparent-checkered-background-size);
background-position:
var(--marker-half-width) 0,
left 0,
right 0,
var(--color-transparent-checkered-background-position);
background-repeat: no-repeat, no-repeat, no-repeat, var(--color-transparent-checkered-background-repeat);
background-repeat: no-repeat, no-repeat, var(--color-transparent-checkered-background-repeat);
border-radius: 2px;
.strip-gradient {
position: absolute;
top: 0;
left: var(--marker-half-width);
width: calc(100% - 2 * var(--marker-half-width));
height: 100%;
}
}
&.narrow .gradient-strip {

View File

@@ -547,7 +547,8 @@ fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list
},
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()
});
@@ -2559,7 +2560,8 @@ fn render_gradient_vello<S: LaneSource<Element = Gradient>>(source: &S, scene: &
kind,
stops,
extend,
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()
});
let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array());

View File

@@ -92,9 +92,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)
}
}
@@ -1296,22 +1296,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.
@@ -2194,6 +2205,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())),
}
}
}