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
Generated
+45 -18
View File
@@ -959,7 +959,7 @@ dependencies = [
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"skrifa", "skrifa 0.42.1",
"tinyvec", "tinyvec",
"tokio", "tokio",
"tsify", "tsify",
@@ -1669,6 +1669,15 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "font-types"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "fontconfig-parser" name = "fontconfig-parser"
version = "0.5.8" version = "0.5.8"
@@ -1702,7 +1711,7 @@ dependencies = [
"linebender_resource_handle", "linebender_resource_handle",
"memmap2 0.9.10", "memmap2 0.9.10",
"parlance", "parlance",
"read-fonts", "read-fonts 0.39.2",
"smallvec", "smallvec",
] ]
@@ -2517,7 +2526,7 @@ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"bytemuck", "bytemuck",
"core_maths", "core_maths",
"read-fonts", "read-fonts 0.39.2",
"smallvec", "smallvec",
] ]
@@ -3979,9 +3988,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
@@ -4108,7 +4117,7 @@ dependencies = [
"linebender_resource_handle", "linebender_resource_handle",
"parlance", "parlance",
"parley_data", "parley_data",
"skrifa", "skrifa 0.42.1",
] ]
[[package]] [[package]]
@@ -4764,7 +4773,18 @@ checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"core_maths", "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]] [[package]]
@@ -4861,7 +4881,7 @@ dependencies = [
"num-traits", "num-traits",
"parley", "parley",
"serde", "serde",
"skrifa", "skrifa 0.42.1",
"text-nodes", "text-nodes",
"usvg", "usvg",
"vector-types", "vector-types",
@@ -5534,7 +5554,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [ dependencies = [
"bytemuck", "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]] [[package]]
@@ -5926,7 +5956,7 @@ dependencies = [
"raster-types", "raster-types",
"serde", "serde",
"serde_json", "serde_json",
"skrifa", "skrifa 0.42.1",
"titlecase", "titlecase",
"tsify", "tsify",
"unicode-segmentation", "unicode-segmentation",
@@ -6623,15 +6653,14 @@ dependencies = [
[[package]] [[package]]
name = "vello" name = "vello"
version = "0.9.0" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/linebender/vello.git?rev=7073a85d61ee099d1d3595651ddf92302b074348#7073a85d61ee099d1d3595651ddf92302b074348"
checksum = "261359dbef879f8110ef7e1c442246c838d33d3d91cb05e0ea9288d432760c9f"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"futures-intrusive", "futures-intrusive",
"log", "log",
"peniko", "peniko",
"png 0.18.1", "png 0.18.1",
"skrifa", "skrifa 0.44.0",
"static_assertions", "static_assertions",
"thiserror 2.0.18", "thiserror 2.0.18",
"vello_encoding", "vello_encoding",
@@ -6642,21 +6671,19 @@ dependencies = [
[[package]] [[package]]
name = "vello_encoding" name = "vello_encoding"
version = "0.9.0" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/linebender/vello.git?rev=7073a85d61ee099d1d3595651ddf92302b074348#7073a85d61ee099d1d3595651ddf92302b074348"
checksum = "2346f5f0d7dccb3582fcd397b4a57b43165209f1424e0d76e85dd814db164af7"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"guillotiere", "guillotiere",
"peniko", "peniko",
"skrifa", "skrifa 0.44.0",
"smallvec", "smallvec",
] ]
[[package]] [[package]]
name = "vello_shaders" name = "vello_shaders"
version = "0.9.0" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/linebender/vello.git?rev=7073a85d61ee099d1d3595651ddf92302b074348#7073a85d61ee099d1d3595651ddf92302b074348"
checksum = "9dd38937516fa4b47423d9255bb5e4a65e839ec9d57c38c4af6189ce56bf46b7"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"log", "log",
+3 -2
View File
@@ -166,8 +166,9 @@ url = "2.5"
tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt", "rt-multi-thread"] } tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt", "rt-multi-thread"] }
# Linebender ecosystem (BEGIN) # Linebender ecosystem (BEGIN)
kurbo = { version = "0.13", features = ["serde"] } kurbo = { version = "0.13", features = ["serde"] }
vello = "0.9" # TODO: Return to crates.io versions once a release after 0.9 ships the ramp bake's `interpolation_alpha_space` support
vello_encoding = "0.9" vello = { git = "https://github.com/linebender/vello.git", rev = "7073a85d61ee099d1d3595651ddf92302b074348" }
vello_encoding = { git = "https://github.com/linebender/vello.git", rev = "7073a85d61ee099d1d3595651ddf92302b074348" }
resvg = "0.47" resvg = "0.47"
usvg = "0.47" usvg = "0.47"
simplecss = "0.2" simplecss = "0.2"
@@ -539,9 +539,14 @@ fn populate_computed_display_fields(layout: &mut Layout) {
hue_direction: spectrum_input.track_hue_direction, hue_direction: spectrum_input.track_hue_direction,
interpolation: spectrum_input.track_interpolation, 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' // 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 cap = |t: f64| {
let color = track_evaluator.evaluate(t); let color = track_evaluator.evaluate(t);
SRGBA8::from(color).to_css_hex() SRGBA8::from(color).to_css_hex()
@@ -586,22 +586,22 @@ pub struct SpectrumInput {
/// The colored gradient drawn behind the markers (display-only, caller-owned). /// The colored gradient drawn behind the markers (display-only, caller-owned).
#[widget_builder(constructor)] #[widget_builder(constructor)]
pub track: GradientStops<SRGBA8>, 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)] #[serde(skip)]
pub track_space: GradientSpace, 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")] #[serde(rename = "trackCyclic")]
pub track_cyclic: bool, 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)] #[serde(skip)]
pub track_hue_direction: GradientHueDirection, 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")] #[serde(rename = "trackInterpolation")]
pub track_interpolation: GradientInterpolation, pub track_interpolation: GradientInterpolation,
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time. /// 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 = "trackCSS")] #[serde(rename = "trackSamples")]
#[widget_builder(skip)] #[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. /// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated by evaluating `track` at position 0.
#[serde(rename = "trackStartCSS")] #[serde(rename = "trackStartCSS")]
#[widget_builder(skip)] #[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. /// 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. /// The last marker's value controls the wrapped interval when `track_cyclic` is set, and is otherwise ignored.
midpoint: f64, 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")] #[serde(rename = "handleColorCSS")]
handle_color_css: String, handle_color_css: String,
} }
impl SpectrumMarker { impl SpectrumMarker {
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self { 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 } 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))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SpectrumInputUpdate { pub enum SpectrumInputUpdate {
@@ -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)); 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 { fn color_to_opaque_hex(color: graphene_std::Color) -> String {
SRGBA8::from(color).to_css_hex() 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 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_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) // 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; 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::End => overlay_context.gradient_color_stop(end, emphasis, &end_hex, !last_at_end),
StopId::Middle(i) => { StopId::Middle(i) => {
if let Some(color) = gradient.color(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);
} }
} }
}; };
@@ -3,14 +3,17 @@
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte"; import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.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_LEFT = 0;
const BUTTON_RIGHT = 2; const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ update: SpectrumInputUpdate; dragging: boolean }>(); 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 trackStartCSS: string;
export let trackEndCSS: string; export let trackEndCSS: string;
export let trackCyclic = false; export let trackCyclic = false;
@@ -396,10 +399,19 @@
styles={{ styles={{
"--gradient-start": trackStartCSS, "--gradient-start": trackStartCSS,
"--gradient-end": trackEndCSS, "--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"> <LayoutRow class="midpoint-track">
{#each midpointPositions as midpoint, index} {#each midpointPositions as midpoint, index}
<svg <svg
@@ -449,26 +461,30 @@
.gradient-strip { .gradient-strip {
flex: 0 0 auto; flex: 0 0 auto;
position: relative;
height: 16px; height: 16px;
background-image: background-image:
var(--gradient-stops),
// Solid start/end colors on either side so the gradient begins at the center of a marker // 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-start), var(--gradient-start)), linear-gradient(var(--gradient-end), var(--gradient-end)), var(--color-transparent-checkered-background);
linear-gradient(var(--gradient-end), var(--gradient-end)),
var(--color-transparent-checkered-background);
background-size: 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 // 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(--marker-half-width) 100%, var(--marker-half-width) 100%,
var(--color-transparent-checkered-background-size); var(--color-transparent-checkered-background-size);
background-position: background-position:
var(--marker-half-width) 0,
left 0, left 0,
right 0, right 0,
var(--color-transparent-checkered-background-position); 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; 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 { &.narrow .gradient-strip {
@@ -538,7 +538,8 @@ fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_trans
}, },
extend: peniko_extend(settings.spread), extend: peniko_extend(settings.spread),
stops: peniko_stops, 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() ..Default::default()
}); });
@@ -2315,7 +2316,7 @@ impl Render for List<Gradient> {
kind, kind,
stops, stops,
extend, extend,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied,
..Default::default() ..Default::default()
}); });
let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array()); let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array());
@@ -91,9 +91,9 @@ impl From<&GradientStops<SRGBA8>> for Gradient {
} }
impl GradientStops<SRGBA8> { impl GradientStops<SRGBA8> {
/// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes). /// CSS `background-image` value drawing the stops as an SVG data URI, keeping straight-alpha interpolation.
pub fn to_css_linear_gradient(&self, settings: GradientSettings) -> String { pub fn to_svg_background_image(&self, settings: GradientSettings) -> String {
Gradient::from(self).to_css_linear_gradient(settings) Gradient::from(self).to_svg_background_image(settings)
} }
} }
@@ -1295,22 +1295,33 @@ impl Gradient {
mapped 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. /// The gradient's [`Gradient::interpolated_samples`], falling back to one black sample when it has no stops, since a
pub fn to_css_linear_gradient(&self, settings: GradientSettings) -> String { /// stopless SVG gradient paints nothing where [`Gradient::evaluate`] gives black.
if self.len() <= 1 { pub fn interpolated_samples_or_black(&self, settings: GradientSettings) -> Vec<(f64, Color, Option<f64>)> {
let hex = self.color(0).map(|c| SRGBA8::from(c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string()); let samples = self.interpolated_samples(settings);
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)"); 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) // A sizeless SVG stretches to fill the CSS background area; the encoding covers the URI-hostile characters
.into_iter() 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>");
.map(|(position, color, _)| { let encoded = svg.replace('%', "%25").replace('#', "%23").replace('<', "%3C").replace('>', "%3E");
let percent = ((position * 100.) * 1e2).round() / 1e2; format!("url(\"data:image/svg+xml,{encoded}\")")
format!("#{} {percent}%", SRGBA8::from(color).to_rgba_hex())
})
.collect::<Vec<_>>()
.join(", ");
format!("linear-gradient(to right, {pieces})")
} }
/// Produce a set of linearly-interpolated color samples that approximate the gradient's true curve. /// 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] #[test]
fn clear_spread_evaluates_to_transparency_outside_the_unit_range() { fn clear_spread_evaluates_to_transparency_outside_the_unit_range() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
@@ -67,7 +67,7 @@ impl<C> FillChoice<C> {
} }
impl FillChoice<SRGBA8> { 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`. /// 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> { pub fn to_css_background_image(&self) -> Option<String> {
match self { match self {
@@ -76,7 +76,7 @@ impl FillChoice<SRGBA8> {
let hex = srgba.to_rgba_hex(); let hex = srgba.to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{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())),
} }
} }
} }