SVG import: gradients with color-interpolation="linearRGB" mix their stops in linear light (#5878)

This commit is contained in:
Don Omondi
2026-11-06 15:33:02 +07:00
committed by bflatastic
parent 968bf1dc48
commit 427ec0017c
5 changed files with 241 additions and 3 deletions
+4
View File
@@ -48,6 +48,7 @@ mod css_transform;
mod depth;
mod files;
mod fx;
mod linear_rgb;
mod spread;
mod symbol_units;
mod text;
@@ -150,6 +151,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
slots,
patterns: HashMap::new(),
midpoints: midpoint_stops(&xml),
linear_rgb: linear_rgb::linear_rgb_ids(&xml, &tree),
labels: labels(&xml),
hidden: found.hidden,
uses: found.uses,
@@ -299,6 +301,8 @@ struct Importer {
patterns: HashMap<usize, String>,
/// Midpoint stops by gradient id (see [`midpoint_stops`]).
midpoints: HashMap<String, Vec<Option<f32>>>,
/// Ids of the gradients mixed in linear light (see [`linear_rgb::linear_rgb_ids`]).
linear_rgb: HashSet<String>,
/// Object names by element id (see [`labels`]).
labels: HashMap<String, String>,
/// Ids of the undisplayed objects shown for usvg ([`prepass`]).
+4 -2
View File
@@ -104,9 +104,10 @@ impl Importer {
}
}
/// A gradient's stops, midpoints restored.
/// A gradient's stops, midpoints restored, and mixed in linear light when it asks for that.
fn stops(&self, g: &usvg::BaseGradient) -> Vec<GradientStop> {
gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice))
let stops = gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice));
if self.linear_rgb.contains(g.id()) { super::linear_rgb::bake(stops) } else { stops }
}
/// The object name for an element id: its label (see [`labels`]), else the id itself.
@@ -728,6 +729,7 @@ mod tests {
slots: TextSlots::default(),
patterns: HashMap::new(),
midpoints: HashMap::new(),
linear_rgb: HashSet::new(),
labels: HashMap::new(),
hidden: HashSet::new(),
uses: HashMap::new(),
+170
View File
@@ -0,0 +1,170 @@
//! Gradients drawn with `color-interpolation="linearRGB"` (SVG 1.1 §11.7.1,
//! <https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty>): their stops mix in
//! linear light, which a document gradient (mixed in display RGB) shows through extra stops.
use super::{Color, GradientStop, HashMap, HashSet, css, roxmltree};
use vectorcraft_color::cms::lab::{linear_to_srgb, srgb_to_linear};
/// How far (display RGB, 0..1) the straight line between neighbouring baked stops may stray from
/// the linear-light mix: under half a colour level.
const TOLERANCE: f32 = 0.4 / 255.0;
/// Most times a span between two stops is halved to keep within [`TOLERANCE`].
const MAX_DEPTH: u32 = 8;
/// Most stops a baked gradient has: a file with very many stops keeps the rest of its spans as
/// they are rather than growing without bound.
pub(super) const MAX_STOPS: usize = 4096;
/// The ids (as usvg names them) of the gradients whose `color-interpolation` is `linearRGB`.
///
/// usvg gives a gradient used by several objects a made-up id (`linearGradient2`…) for each user
/// but the last; those are found by their stops and geometry, matching an original gradient's
/// only when no other original gradient with the same stops and geometry interpolates otherwise.
pub(super) fn linear_rgb_ids(xml: &roxmltree::Document, tree: &usvg::Tree) -> HashSet<String> {
if !xml.input_text().contains("linearRGB") {
return HashSet::new();
}
let styles = css::Styles::new(xml);
let mut linear = HashSet::new();
let mut named = HashSet::new();
for g in xml.descendants().filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient")) {
let Some(id) = g.attribute("id") else { continue };
named.insert(id);
if styles.prop(g, "color-interpolation").as_deref() == Some("linearRGB") {
linear.insert(id.to_string());
}
}
if linear.is_empty() {
return linear;
}
let gradients = || {
let lin = tree.linear_gradients().iter().map(|g| (g.id(), signature(g, &[g.x1(), g.y1(), g.x2(), g.y2()])));
let rad = tree.radial_gradients().iter().map(|g| (g.id(), signature(g, &[g.cx(), g.cy(), g.r().get(), g.fx(), g.fy(), g.fr().get()])));
lin.chain(rad)
};
// Each original gradient's look → whether all the originals that look so are linearRGB.
let mut by_look: HashMap<Vec<u32>, bool> = HashMap::new();
for (id, sig) in gradients().filter(|(id, _)| named.contains(id)) {
let is_linear = linear.contains(id);
by_look.entry(sig).and_modify(|v| *v &= is_linear).or_insert(is_linear);
}
let copies: Vec<String> =
gradients().filter(|(id, sig)| !named.contains(id) && by_look.get(sig) == Some(&true)).map(|(id, _)| id.to_string()).collect();
linear.extend(copies);
linear
}
/// What tells two usvg gradients apart but for their id, units and transform (what usvg changes
/// on the copies it makes): their geometry `geom`, spread method and stops.
fn signature(g: &usvg::BaseGradient, geom: &[f32]) -> Vec<u32> {
let spread = match g.spread_method() {
usvg::SpreadMethod::Pad => 0,
usvg::SpreadMethod::Reflect => 1,
usvg::SpreadMethod::Repeat => 2,
};
let mut sig: Vec<u32> = geom.iter().map(|v| v.to_bits()).collect();
sig.push(spread);
for s in g.stops() {
let c = s.color();
sig.extend([s.offset().get().to_bits(), u32::from_be_bytes([0, c.red, c.green, c.blue]), s.opacity().get().to_bits()]);
}
sig
}
/// `stops`, mixed in linear light: between each two stops, stops where the display-RGB line
/// strays from the linear-light mix (opacity still mixes evenly). Spans with a moved midpoint
/// (only our own export writes those, never with linearRGB) keep their midpoint unbaked.
pub(super) fn bake(stops: Vec<GradientStop>) -> Vec<GradientStop> {
let mut out: Vec<GradientStop> = Vec::with_capacity(stops.len());
let mut it = stops.into_iter().peekable();
while let Some(a) = it.next() {
let next = it.peek().cloned();
out.push(a.clone());
let Some(b) = next else { break };
if b.offset <= a.offset || (a.midpoint - 0.5).abs() > 1e-6 || a.color.to_rgb() == b.color.to_rgb() {
continue;
}
let mut ts = Vec::new();
steps(&a.color, &b.color, (0.0, 1.0), 1, &mut ts);
let room = MAX_STOPS.saturating_sub(out.len() + it.len());
for t in ts.into_iter().take(room) {
let opacity = a.opacity + (b.opacity - a.opacity) * t;
out.push(GradientStop { opacity, ..GradientStop::new(a.offset + (b.offset - a.offset) * t, mix(&a.color, &b.color, t)) });
}
}
out
}
/// `a` and `b` mixed at `t` (0..1) in linear light, as display RGB.
fn mix(a: &Color, b: &Color, t: f32) -> Color {
let (p, q) = (a.to_rgb(), b.to_rgb());
let ch = |k: usize| {
let (x, y) = (srgb_to_linear(p[k]), srgb_to_linear(q[k]));
let v = linear_to_srgb(x + (y - x) * t);
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
};
Color::rgb(ch(0), ch(1), ch(2))
}
/// The mix positions (after `t0`, before `t1`) between which display RGB mixed straight stays
/// within [`TOLERANCE`] of the linear-light mix of `a` and `b`, in order.
fn steps(a: &Color, b: &Color, (t0, t1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
let at = |t: f32| mix(a, b, t).to_rgb();
let (c0, c1) = (at(t0), at(t1));
let stray = [0.25, 0.5, 0.75]
.into_iter()
.flat_map(|u: f32| {
let c = at(t0 + (t1 - t0) * u);
(0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs())
})
.fold(0.0, f32::max);
if depth <= MAX_DEPTH && stray > TOLERANCE {
let m = (t0 + t1) / 2.0;
steps(a, b, (t0, m), depth + 1, out);
out.push(m);
steps(a, b, (m, t1), depth + 1, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(stops: &[GradientStop], x: f32) -> [f32; 3] {
vectorcraft_color::Gradient::new(vectorcraft_color::GradientKind::Linear, stops.to_vec()).sample(x).0.to_rgb()
}
#[test]
fn black_to_white_is_brighter_mid_way() {
let baked = bake(vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]);
assert!(baked.len() > 2 && baked.len() < 300, "{}", baked.len());
// A balanced linear-light mix of black and white encodes to about 188/255.
let mid = sample(&baked, 0.5)[0] * 255.0;
assert!((mid - 188.0).abs() < 1.0, "{mid}");
for i in 0..=100 {
let x = i as f32 / 100.0;
let want = linear_to_srgb(x);
assert!((sample(&baked, x)[0] - want).abs() < 1.0 / 255.0, "at {x}");
}
assert!(baked.windows(2).all(|w| w[0].offset <= w[1].offset));
}
#[test]
fn equal_colours_and_moved_midpoints_stay_as_they_are() {
let same = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::BLACK)];
assert_eq!(bake(same.clone()), same);
let moved = vec![GradientStop { midpoint: 0.3, ..GradientStop::new(0.0, Color::BLACK) }, GradientStop::new(1.0, Color::WHITE)];
assert_eq!(bake(moved.clone()), moved);
}
#[test]
fn opacity_mixes_evenly_and_the_stop_count_is_capped() {
let a = GradientStop { opacity: 0.0, ..GradientStop::new(0.0, Color::BLACK) };
let baked = bake(vec![a, GradientStop::new(1.0, Color::WHITE)]);
assert!(baked.iter().all(|s| (s.opacity - s.offset).abs() < 1e-5));
let many: Vec<GradientStop> =
(0..3000).map(|i| GradientStop::new(i as f32 / 2999.0, if i % 2 == 0 { Color::BLACK } else { Color::WHITE })).collect();
let baked = bake(many);
assert_eq!(baked.len(), MAX_STOPS);
assert_eq!(baked.last().map(|s| s.offset), Some(1.0), "every original stop is kept");
}
}
+61
View File
@@ -100,3 +100,64 @@ fn midpoints_come_back() {
assert!((g.gradient.stops[0].midpoint - 0.25).abs() < 1e-6);
assert!((g.gradient.stops[1].midpoint - 0.5).abs() < 1e-6);
}
/// The gradient fills of every path `svg` imports to, in order.
fn imported_gradients(svg: &str) -> Vec<Gradient> {
fn walk(n: &Node, out: &mut Vec<Gradient>) {
if let Paint::Gradient(g) = n.appearance.fill_paint() {
out.push(g.gradient.clone());
}
for c in n.children().into_iter().flatten() {
walk(c, out);
}
}
let d = import(svg).unwrap();
let mut out = vec![];
for l in &d.layers {
walk(l, &mut out);
}
out
}
/// SVG 1.1 §11.7.1 (https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty):
/// `color-interpolation="linearRGB"` mixes a gradient's stops in linear light (#5878).
#[test]
fn linear_rgb_gradients_mix_in_linear_light() {
let svg = |interp: &str| {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<defs><linearGradient id="g" color-interpolation="{interp}"><stop stop-color="black"/><stop offset="1" stop-color="white"/></linearGradient></defs>
<rect width="200" height="200" fill="url(#g)"/></svg>"#
)
};
let mid = |g: &Gradient| g.sample(0.5).0.to_rgb()[0] * 255.0;
let linear = imported_gradients(&svg("linearRGB"));
assert_eq!(linear.len(), 1);
assert!(linear[0].stops.len() > 2);
// A balanced linear-light mix of black and white encodes to about 188/255.
assert!((mid(&linear[0]) - 188.0).abs() < 1.0, "{}", mid(&linear[0]));
let srgb = imported_gradients(&svg("sRGB"));
assert_eq!(srgb[0].stops.len(), 2);
assert!((mid(&srgb[0]) - 127.5).abs() < 1.0, "{}", mid(&srgb[0]));
}
/// The property inherits and cascades; a gradient several objects share (which usvg copies) is
/// mixed in linear light on every one of them, and one asking for sRGB with the same stops isn't.
#[test]
fn linear_rgb_inherits_cascades_and_holds_for_every_user() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100">
<style>.lin { color-interpolation: linearRGB }</style>
<defs style="color-interpolation: linearRGB">
<linearGradient id="a"><stop stop-color="black"/><stop offset="1" stop-color="white"/></linearGradient>
<linearGradient id="b" color-interpolation="sRGB"><stop stop-color="red"/><stop offset="1" stop-color="blue"/></linearGradient>
</defs>
<linearGradient id="c" class="lin"><stop stop-color="red"/><stop offset="1" stop-color="lime"/></linearGradient>
<rect width="50" height="50" fill="url(#a)"/><rect x="60" width="80" height="50" fill="url(#a)"/>
<rect x="150" width="80" height="50" fill="url(#a)"/><rect x="240" width="50" height="50" fill="url(#b)"/>
<rect x="300" width="50" height="50" fill="url(#c)"/></svg>"#;
let counts: Vec<usize> = imported_gradients(svg).iter().map(|g| g.stops.len()).collect();
assert_eq!(counts.len(), 5);
assert!(counts[..3].iter().all(|&n| n > 2), "{counts:?}");
assert_eq!(counts[3], 2, "{counts:?}");
assert!(counts[4] > 2, "{counts:?}");
}