diff --git a/Cargo.lock b/Cargo.lock index f2d8864844..381d5858b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 8aa7bf7a98..94050f9b09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 536ed7caaf..9df6018b8b 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -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() diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 5eb058c697..ffa321ebe2 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -586,22 +586,22 @@ pub struct SpectrumInput { /// The colored gradient drawn behind the markers (display-only, caller-owned). #[widget_builder(constructor)] pub track: GradientStops, - /// 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, /// 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 { diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 7fe80eee50..b04175f927 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -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); } } }; diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index d7299ec387..f08caa12f9 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -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, }} > - + + + + + {#each trackSamples as sample} + + {/each} + + + + {#each midpointPositions as midpoint, index} >(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>(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()); diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 084ea1b142..a9a41bfd72 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -92,9 +92,9 @@ impl From<&GradientStops> for Gradient { } impl GradientStops { - /// 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)> { + 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, ""); } - 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::>() - .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!("{stops}"); + 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]); diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 3b4e18770c..ee0b154d11 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -67,7 +67,7 @@ impl FillChoice { } impl FillChoice { - /// 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 { match self { @@ -76,7 +76,7 @@ impl FillChoice { 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())), } } }