mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
Merge pull request #5909 from mockydevs/fix/svg-blur-linearrgb-5877
SVG import: Gaussian blurs keep color-interpolation-filters; Gaussian Blur can blur in linear light (#5877)
This commit is contained in:
@@ -307,7 +307,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}),
|
||||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"blur.gaussian",
|
||||
"Gaussian Blur…",
|
||||
BLUR,
|
||||
"{radius: pt (5), linear?: bool (false; blurs the colours in linear light, as an SVG filter with color-interpolation-filters=\"linearRGB\" does)}",
|
||||
json!({"radius": 5.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.coloredPencil",
|
||||
"Colored Pencil…",
|
||||
|
||||
@@ -1079,6 +1079,47 @@ pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`gaussian_rgba`] with the colours mixed in linear light: decoded from sRGB before the blur
|
||||
/// and encoded again after it (Filter Effects 1 §16, `color-interpolation-filters: linearRGB`,
|
||||
/// <https://www.w3.org/TR/filter-effects-1/#ColorInterpolationFiltersProperty>). The blur runs on
|
||||
/// 16-bit values, so dark linear tones keep their steps.
|
||||
pub fn gaussian_rgba_linear(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
use vectorcraft_doc::color::cms::lab::{linear_to_srgb, srgb_to_linear};
|
||||
if let Some(px) = pixels(data, w, h)
|
||||
&& sigma >= 0.2
|
||||
{
|
||||
// Each colour is unpremultiplied, decoded (by table) and premultiplied again.
|
||||
let decode: Vec<f32> = (0..=255u8).map(|v| srgb_to_linear(f32::from(v) / 255.0)).collect();
|
||||
let mut wide: Vec<Px16> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let lin = |v: u8| {
|
||||
if p[3] == 0 {
|
||||
return 0;
|
||||
}
|
||||
let straight = (f32::from(v) / f32::from(p[3]) * 255.0).round().clamp(0.0, 255.0) as usize;
|
||||
(decode.get(straight).copied().unwrap_or(0.0) * a * 65535.0).round().clamp(0.0, 65535.0) as u16
|
||||
};
|
||||
[lin(p[0]), lin(p[1]), lin(p[2]), u16::from(p[3]) * 257]
|
||||
})
|
||||
.collect();
|
||||
gaussian(&mut wide, w, sigma);
|
||||
for (p, q) in px.iter_mut().zip(&wide) {
|
||||
let a = to8(q[3]);
|
||||
let alpha = f32::from(q[3]) / 65535.0;
|
||||
let enc = |v: u16| {
|
||||
if alpha <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let straight = (f32::from(v) / 65535.0 / alpha).clamp(0.0, 1.0);
|
||||
(linear_to_srgb(straight) * f32::from(a)).round().clamp(0.0, f32::from(a)) as u8
|
||||
};
|
||||
*p = [enc(q[0]), enc(q[1]), enc(q[2]), a];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -887,6 +887,28 @@ fn gaussian_and_plane_blurs_keep_their_mass() {
|
||||
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
|
||||
}
|
||||
|
||||
/// Blurred in linear light, black beside white meets at about 188/255 (half the light, encoded),
|
||||
/// where the plain blur meets at about 128; flat areas and alpha stay as they are (#5877).
|
||||
#[test]
|
||||
fn linear_light_blur_mixes_light_not_values() {
|
||||
// Large enough that the transparency past the edges doesn't reach the middle.
|
||||
let (w, h) = (120, 60);
|
||||
let src = image(w, h, |x, _| if x < 60 { [0, 0, 0, 255] } else { [255, 255, 255, 255] });
|
||||
let (mut plain, mut linear) = (src.clone(), src.clone());
|
||||
gaussian_rgba(&mut plain, w, h, 3.0);
|
||||
gaussian_rgba_linear(&mut linear, w, h, 3.0);
|
||||
let mid = |d: &[u8]| (f32::from(at(d, w, 59, 30)[0]) + f32::from(at(d, w, 60, 30)[0])) / 2.0;
|
||||
assert!((mid(&plain) - 128.0).abs() < 6.0, "{}", mid(&plain));
|
||||
assert!((mid(&linear) - 188.0).abs() < 6.0, "{}", mid(&linear));
|
||||
assert_eq!((at(&linear, w, 30, 30), at(&linear, w, 90, 30)), ([0, 0, 0, 255], [255, 255, 255, 255]));
|
||||
// Premultiplied input: a half-transparent white square blurs to white at any alpha.
|
||||
let mut soft = image(30, 30, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [128, 128, 128, 128] } else { [0, 0, 0, 0] });
|
||||
gaussian_rgba_linear(&mut soft, 30, 30, 2.0);
|
||||
for p in soft.as_chunks::<4>().0 {
|
||||
assert!(p[0] <= p[3] && p[3] <= 128 && (p[3] < 8 || p[0] + 2 >= p[3]), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
|
||||
/// --nocapture pixel_timings`).
|
||||
#[test]
|
||||
|
||||
@@ -48,9 +48,11 @@ pub enum RasterFx {
|
||||
Feather {
|
||||
radius: f64,
|
||||
},
|
||||
/// Blur the whole object (σ = radius / 2).
|
||||
/// Blur the whole object (σ = radius / 2): its colours mixed as they are, or in linear light
|
||||
/// (`linear`, as an SVG filter asks with `color-interpolation-filters="linearRGB"`).
|
||||
GaussianBlur {
|
||||
radius: f64,
|
||||
linear: bool,
|
||||
},
|
||||
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
|
||||
Pixel(PixelFx),
|
||||
@@ -77,7 +79,7 @@ impl RasterFx {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
|
||||
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
|
||||
RasterFx::GaussianBlur { radius } => 1.5 * radius,
|
||||
RasterFx::GaussianBlur { radius, .. } => 1.5 * radius,
|
||||
RasterFx::Pixel(p) => p.outset(bounds),
|
||||
RasterFx::Rasterize(r) => r.outset(),
|
||||
}
|
||||
@@ -416,7 +418,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0), linear: flag(&p, "linear", false) },
|
||||
RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)),
|
||||
id => RasterFx::Pixel(pixel::parse(id, &p)?),
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ fn effect_params_are_the_effects_own() {
|
||||
check_effect_params("effect.apply", e.id, &e.defaults).unwrap_or_else(|why| panic!("{}'s defaults: {why}", e.id));
|
||||
}
|
||||
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
|
||||
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"blurr": 3}))), "unknown parameter `blurr`; the command takes: radius");
|
||||
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"blurr": 3}))), "unknown parameter `blurr`; the command takes: radius, linear");
|
||||
assert_eq!(
|
||||
refusal("appearance.addEffect", apply("stylize.dropShadow", json!({"opacty": 50}))),
|
||||
"unknown parameter `opacty`; did you mean `opacity`?"
|
||||
|
||||
@@ -236,7 +236,10 @@ impl Renderer {
|
||||
bounds = fx.spread(before, space.line);
|
||||
let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline);
|
||||
match fx {
|
||||
RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px),
|
||||
RasterFx::GaussianBlur { radius, linear } => {
|
||||
let blur = if *linear { effects::pixel::gaussian_rgba_linear } else { effects::pixel::gaussian_rgba };
|
||||
blur(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px)
|
||||
}
|
||||
RasterFx::Feather { radius } => {
|
||||
effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px);
|
||||
if let Some((g, rule)) = outline {
|
||||
|
||||
@@ -74,7 +74,7 @@ pub(super) fn hard_edged(chain: &[&RasterFx], px: f64) -> bool {
|
||||
/// document's raster grid lines `line` apart; `None` when anywhere in the object.
|
||||
pub(super) fn looks_around(fx: &RasterFx, line: f64) -> Option<f64> {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius),
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => Some(1.5 * radius),
|
||||
RasterFx::Pixel(p) => p.reach(line),
|
||||
RasterFx::Rasterize(r) => Some(r.reach()),
|
||||
_ => Some(0.0),
|
||||
|
||||
@@ -691,7 +691,7 @@ impl Renderer {
|
||||
// The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur
|
||||
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
|
||||
// and looked blocky as the view moved by parts of a pixel (#755).
|
||||
let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0));
|
||||
let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } if *radius > 0.0));
|
||||
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) {
|
||||
self.pixel_content(ctx, f, content, rfx, paint);
|
||||
} else {
|
||||
|
||||
@@ -668,7 +668,7 @@ impl Rules<'_> {
|
||||
RasterFx::InnerGlow { opacity, blur, color, center: false, .. } if !text => {
|
||||
shadows.push(format!("inset 0 0 {} {}", self.len(blur), color_css(&color, opacity)))
|
||||
}
|
||||
RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))),
|
||||
RasterFx::GaussianBlur { radius, .. } => filter = Some(format!("blur({})", self.len(radius / 2.0))),
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"),
|
||||
RasterFx::Pixel(_) | RasterFx::Rasterize(_) => art.unsupported("pixel effects"),
|
||||
}
|
||||
|
||||
@@ -601,7 +601,10 @@ impl Writer<'_> {
|
||||
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"soft\"/><feComposite in=\"SourceGraphic\" in2=\"soft\" operator=\"in\" result=\"f\"/><feComposite in=\"f\" in2=\"SourceAlpha\" operator=\"in\"/>",
|
||||
sd(*radius)
|
||||
),
|
||||
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
|
||||
RasterFx::GaussianBlur { radius, linear } => {
|
||||
let space = if *linear { " color-interpolation-filters=\"linearRGB\"" } else { "" };
|
||||
format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"{space}/>", sd(*radius))
|
||||
}
|
||||
RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue,
|
||||
};
|
||||
// Filters composite normally: a shadow's or glow's other blend mode is recorded for import.
|
||||
|
||||
@@ -347,7 +347,9 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
|
||||
let (id, params) = match eval(f, k)? {
|
||||
V::Graphic => continue,
|
||||
V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })),
|
||||
// SVG blurs in linear light unless the filter asks for sRGB (`color-interpolation-filters`).
|
||||
V::Blurred(sd, true) => ("blur.gaussian", json!({ "radius": blur(sd), "linear": true })),
|
||||
V::Blurred(sd, false) => ("blur.gaussian", json!({ "radius": blur(sd) })),
|
||||
V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),
|
||||
V::Stack(layers) => match layers.as_slice() {
|
||||
[V::Paint { shape, color, opacity, clipped: false }, V::Graphic] if !shape.inverted => {
|
||||
|
||||
@@ -239,7 +239,7 @@ fn a_figma_blurred_shape_filter_becomes_gaussian_blur() {
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let fx = effects(&d);
|
||||
assert_eq!(fx.len(), 1);
|
||||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0})));
|
||||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0, "linear": true})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -274,7 +274,7 @@ fn a_gaussian_blur_becomes_the_effect_with_its_radius() {
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let fx = effects(&d);
|
||||
assert_eq!(fx.len(), 1);
|
||||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0})));
|
||||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0, "linear": true})));
|
||||
// In a scaled group, the radius scales too.
|
||||
let (d, _) = open(
|
||||
r##"<defs><filter id="b"><feGaussianBlur stdDeviation="3"/></filter></defs><g transform="scale(2)"><rect width="30" height="20" filter="url(#b)"/></g>"##,
|
||||
@@ -282,6 +282,44 @@ fn a_gaussian_blur_becomes_the_effect_with_its_radius() {
|
||||
assert_eq!(effects(&d)[0].params["radius"], json!(12.0));
|
||||
}
|
||||
|
||||
/// `color-interpolation-filters` (SVG 1.1 §15.7.6, initially linearRGB:
|
||||
/// https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationFiltersProperty) picks whether the
|
||||
/// blur mixes colours in linear light; sRGB, on the primitive or the filter, keeps them as they
|
||||
/// are (#5877).
|
||||
#[test]
|
||||
fn a_gaussian_blur_keeps_its_colour_space() {
|
||||
let blur = |filter: &str, primitive: &str| {
|
||||
let (d, w) = open(&format!(
|
||||
r##"<defs><filter id="b"{filter}><feGaussianBlur stdDeviation="10"{primitive}/></filter></defs><rect width="100" height="200" filter="url(#b)"/>"##
|
||||
));
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
effects(&d)[0].params.clone()
|
||||
};
|
||||
let linear = json!({"radius": 20.0, "linear": true});
|
||||
let srgb = json!({"radius": 20.0});
|
||||
assert_eq!(blur("", r#" color-interpolation-filters="linearRGB""#), linear);
|
||||
assert_eq!(blur("", ""), linear, "linearRGB is the initial value");
|
||||
assert_eq!(blur("", r#" color-interpolation-filters="sRGB""#), srgb);
|
||||
assert_eq!(blur(r#" color-interpolation-filters="sRGB""#, ""), srgb, "inherited from the filter");
|
||||
// Export writes the space back on the blur, so it survives a round trip.
|
||||
let (d, _) =
|
||||
open(r##"<defs><filter id="b"><feGaussianBlur stdDeviation="10"/></filter></defs><rect width="100" height="200" filter="url(#b)"/>"##);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains(r#"color-interpolation-filters="linearRGB"/>"#), "{svg}");
|
||||
assert_eq!(effects(&import(&svg).unwrap())[0].params, linear);
|
||||
// Drawn, black beside white meets as resvg (which blurs in either space) draws it.
|
||||
for space in ["linearRGB", "sRGB"] {
|
||||
let source = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><filter id="f" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="10" color-interpolation-filters="{space}"/></filter></defs><g filter="url(#f)"><rect width="100" height="200"/><rect x="100" width="100" height="200" fill="white"/></g></svg>"##
|
||||
);
|
||||
let (ours, theirs) = (render_artboard(&import(&source).unwrap()), resvg_render(&source, 200, 200));
|
||||
for x in [95, 99, 100, 105] {
|
||||
let at = 4 * (100 * 200 + x) as usize;
|
||||
assert!(ours.rgba[at].abs_diff(theirs.rgba[at]) <= 6, "{space} at {x}: {} vs {}", ours.rgba[at], theirs.rgba[at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one drop shadow of the only object: (dx, dy, blur, colour, opacity %).
|
||||
fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) {
|
||||
let fx = vectorcraft_effects::raster_effects(&effects(d));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.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: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **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
|
||||
@@ -191,6 +191,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 Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged |
|
||||
| 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged |
|
||||
| 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` |
|
||||
|
||||
Reference in New Issue
Block a user