mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
committed by
Dennis Kobert
parent
9181a62e99
commit
da3ffa1721
63
Cargo.lock
generated
63
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user