From 41ceaadc7e5149c7b766981f91d3b4aeab85a31a Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:01:37 -0500 Subject: [PATCH] SVG import: Gaussian blurs keep color-interpolation-filters; Gaussian Blur can blur in linear light (#5877) --- crates/effects/src/catalog.rs | 8 +++++- crates/effects/src/pixel.rs | 41 +++++++++++++++++++++++++++++ crates/effects/src/pixel/tests.rs | 22 ++++++++++++++++ crates/effects/src/raster.rs | 8 +++--- crates/engine/src/tests/params.rs | 2 +- crates/render/src/fx/pixels.rs | 5 +++- crates/render/src/fx/raster.rs | 2 +- crates/render/src/fx/renderer.rs | 2 +- crates/svg/src/css.rs | 2 +- crates/svg/src/export/paint.rs | 5 +++- crates/svg/src/import/fx.rs | 4 ++- crates/svg/tests/it/import_live.rs | 42 ++++++++++++++++++++++++++++-- docs/file-format-parity.md | 3 ++- 13 files changed, 132 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index d1560ec7c..844063932 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -307,7 +307,13 @@ pub fn effect_catalog() -> Vec { 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…", diff --git a/crates/effects/src/pixel.rs b/crates/effects/src/pixel.rs index 2a122bb66..6be45756b 100644 --- a/crates/effects/src/pixel.rs +++ b/crates/effects/src/pixel.rs @@ -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`, +/// ). 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 = (0..=255u8).map(|v| srgb_to_linear(f32::from(v) / 255.0)).collect(); + let mut wide: Vec = 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)] diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 8261d408a..a1a083f0f 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -887,6 +887,28 @@ fn gaussian_and_plane_blurs_keep_their_mass() { assert!((plane.iter().sum::() - 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] diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index a1d8df2d6..cfa51ca2b 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -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 { 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)?), }) diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index e989597ae..2eba6c81a 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -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`?" diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 8159673ca..3f13d1b3e 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -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 { diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index 23f080daf..5fa9b107e 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -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 { 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), diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index fae143a23..fd8d68a98 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -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 { diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 1b19da801..78113dcd8 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -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"), } diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index 462387d70..902bfc7e1 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -601,7 +601,10 @@ impl Writer<'_> { "", sd(*radius) ), - RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), + RasterFx::GaussianBlur { radius, linear } => { + let space = if *linear { " color-interpolation-filters=\"linearRGB\"" } else { "" }; + format!("", sd(*radius)) + } RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, }; // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 8c809f0cd..e5da2936d 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -347,7 +347,9 @@ pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap 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 => { diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index b4dd58256..13e32ebf5 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -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##""##, @@ -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##""## + )); + 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##""##); + 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##""## + ); + 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)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a3e1aca39..6d17acd7e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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` |