mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 13:23:22 +00:00
SVG import: gradients with color-interpolation="linearRGB" mix their stops in linear light (#5878)
This commit is contained in:
@@ -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`]).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: `color-interpolation="linearRGB"` gradients, #5878) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -189,6 +189,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | SVG import: a gradient with `color-interpolation="linearRGB"` (on it, inherited, or from a style sheet) mixes its stops in linear light, as SVG 1.1 §11.7.1 asks: the document gradient gets extra stops wherever display-RGB mixing would stray by half a colour level or more, so black to white is about 188/255 mid-way instead of 128 (#5878). A gradient several objects share is mixed so on every one. Opacity still mixes evenly; at most 4096 stops per gradient. Tests `import::linear_rgb`, `gradients::linear_rgb_*` (crate `vectorcraft-svg`); scores unchanged |
|
||||
| 2026-11-05 | minor | `.ai` import (#372): a path with a Calligraphic brush comes in as the path with the brush on its stroke (its weight scales the brush) when its art style's brush settings are the brush's own and its angle is on the page, else as its drawn look; a Calligraphic brush's Tilt, Bearing and Rotation codes come in as those channels instead of Random. Checked against a user's file of 6D Art Pen strokes (used locally, not committed); synthetic tests in `import::ai::tests` |
|
||||
| 2026-11-05 | minor | PDF import: the stops of an axial or radial shading in a DeviceN space of one spot ink and process plates link to the ink's swatch and keep the plates (`GradientStop.plates` in the native format); PDF and EPS export write them in a DeviceN space again (#3235) |
|
||||
| 2026-11-04 | minor | Raster/PSD export uses document resolution unless ppi or scale is supplied; CLI run/convert accept --ppi. Synthetic engine, CLI and MCP regressions cover dimensions and override precedence; scores and hours unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user