diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 3b0f2001d..49bcfaab7 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -145,7 +145,10 @@ impl Importer { let url = self.links.get(g.id()).cloned(); let mut n = match self.instance(g, acc) { Some(n) => n, - None => self.group_node(g, acc)?, + None => { + let n = self.group_node(g, acc)?; + self.filter_regions(g, acc * aff(g.transform()), n)? + } }; if let Some(url) = url { link(&mut n, &url); @@ -361,6 +364,58 @@ impl Importer { Some(n) } + /// `n`, made of group `g` (whose user space `ts` maps to the document), clipped to the regions + /// of the filters it imported as effects: SVG 1.1 §15.5 + /// () cuts a filter's result at + /// its region. A filter that didn't import (drawn without it) clips nothing. + fn filter_regions(&mut self, g: &usvg::Group, ts: Affine, mut n: Node) -> Option { + if g.filters().is_empty() || fx::effects(g.filters(), ts, &self.blends).is_none() { + return Some(n); + } + for f in g.filters() { + n = self.region_clipped(n, f.rect(), ts)?; + } + Some(n) + } + + /// `n` in a clip group cut to `region` (a filter's or mask's, in the user space `ts` maps to + /// the document), unless all it paints (its visual bounds, grown by the reach of its effects) + /// lies inside the region. `None` only past the object limit. + fn region_clipped(&mut self, n: Node, region: usvg::NonZeroRect, ts: Affine) -> Option { + let r = Rect::new(region.left() as f64, region.top() as f64, region.right() as f64, region.bottom() as f64); + let det = ts.determinant(); + let Some(bounds) = n.visual_bounds_with(&self.doc.symbol_boxes()) else { return Some(n) }; + if !det.is_finite() || det.abs() < 1e-12 { + return Some(n); + } + // A clip group paints inside its clipping path; anything else as far as its effects reach + // (as export sizes the filter regions it writes: an Outer Glow grows by a quarter of a + // raster pixel first). + let reach = if matches!(n.kind, NodeKind::Group { clip: true, .. }) { + 0.0 + } else { + let grow = 18.0 / self.doc.raster_ppi(); + vectorcraft_effects::raster_effects(&n.appearance.effects) + .iter() + .map(|f| f.outset(bounds) + if matches!(f, vectorcraft_effects::RasterFx::OuterGlow { .. }) { grow } else { 0.0 }) + .sum() + }; + let painted = bounds.inflate(reach, reach); + let to_user = ts.inverse(); + let eps = 1e-6 * (1.0 + r.width().max(r.height())); + let inside = + [(painted.x0, painted.y0), (painted.x1, painted.y0), (painted.x1, painted.y1), (painted.x0, painted.y1)].into_iter().all(|(x, y)| { + let p = to_user * Point::new(x, y); + p.x >= r.x0 - eps && p.x <= r.x1 + eps && p.y >= r.y0 - eps && p.y <= r.y1 + eps + }); + if inside { + return Some(n); + } + let path = shapes::rectangle(r).transformed(ts); + let clip = self.named("", NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: true, guide: false })?; + self.named("", NodeKind::Group { children: vec![Arc::new(clip), Arc::new(n)], clip: true }) + } + /// `` → opacity mask. An alpha mask's art comes in white with its opacity kept, so its /// luminance is its alpha (images in it keep their colours, with a warning). A mask our export wrote gets its /// options back ([`mask_flags`]) and its art without what stands for them: the inverting @@ -370,6 +425,7 @@ impl Importer { self.warn(format!("nested mask on {label} ignored")); } let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false)); + let user = ts; let (mut nodes, mut ts) = (m.root().children(), ts); if invert && let [usvg::Node::Group(g)] = nodes @@ -397,6 +453,9 @@ impl Importer { 1 => Arc::unwrap_or_clone(children.pop()?), _ => self.named("", NodeKind::Group { children, clip: false })?, }; + // SVG 1.1 §14.4 (https://www.w3.org/TR/SVG11/masking.html#MaskElement): the mask's art + // counts only inside its region. + let art = self.region_clipped(art, m.rect(), user)?; let mut mask = vectorcraft_doc::OpacityMask::new(art, clip); mask.invert = invert; Some(Box::new(mask)) diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 13e32ebf5..b93314b54 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -111,7 +111,9 @@ fn resvg_render(svg: &str, w: u32, h: u32) -> Image { /// The effects of the only object. fn effects(d: &Document) -> Vec { - let a = art(d); + // Not the clip group a filter's region may put it in (SVG 1.1 §15.5), nor its clipping path. + let a: Vec<&Node> = + art(d).into_iter().filter(|n| !matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Path { clipping: true, .. })).collect(); assert_eq!(a.len(), 1, "{a:?}"); a[0].appearance.effects.clone() } @@ -320,6 +322,60 @@ fn a_gaussian_blur_keeps_its_colour_space() { } } +/// The colour of pixel (`x`, `y`) of `svg` imported and drawn on white. +fn drawn_at(svg: &str, x: u32, y: u32) -> [u8; 4] { + let img = render_artboard(&import(svg).unwrap()); + let at = 4 * (y * img.width + x) as usize; + [img.rgba[at], img.rgba[at + 1], img.rgba[at + 2], img.rgba[at + 3]] +} + +/// SVG 1.1 §15.5 (https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion): a filter's +/// result is cut at its region, so a blurred square shows only inside it (#5876). A region that +/// holds the whole blur adds no clip. +#[test] +fn a_blur_is_cut_at_its_filter_region() { + let doc = |width: u32| { + format!( + r##""## + ) + }; + let red = |p: [u8; 4]| p[0] > 240 && p[1] < 30 && p[2] < 30; + assert!(red(drawn_at(&doc(50), 25, 100))); + assert_eq!(drawn_at(&doc(50), 100, 100), [255, 255, 255, 255], "outside the region"); + assert!(red(drawn_at(&doc(200), 100, 100))); + // With room for the blur all round, the object is as before: no clip group. + let roomy = r##""##; + let d = import(roomy).unwrap(); + assert_eq!(art(&d).len(), 1); + // Our own export sizes the region to the blur: it comes back unclipped. + let back = import(&export(&d, &ExportOptions::default())).unwrap(); + assert_eq!(art(&back).len(), 1); +} + +/// SVG 1.1 §14.4 (https://www.w3.org/TR/SVG11/masking.html#MaskElement): a mask's art counts +/// only inside the mask's region, in user space or the object's bounding box (#5875). +#[test] +fn a_mask_counts_only_inside_its_region() { + let doc = |region: &str| { + format!( + r##""## + ) + }; + let red = [255, 0, 0, 255]; + for region in + [r#"maskUnits="userSpaceOnUse" x="0" y="0" width="50" height="200""#, r#"maskUnits="objectBoundingBox" x="0" y="0" width="0.25" height="1""#] + { + assert_eq!(drawn_at(&doc(region), 25, 100), red, "{region}"); + assert_eq!(drawn_at(&doc(region), 100, 100), [255, 255, 255, 255], "{region}"); + } + let wide = doc(r#"maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200""#); + assert_eq!(drawn_at(&wide, 100, 100), red); + // The default region (-10% to 120% of the object) holds the art: the mask art is as before. + let d = import(&doc("")).unwrap(); + let mask = art(&d)[0].extras().mask.clone().unwrap(); + assert!(matches!(mask.art.kind, NodeKind::Path { .. }), "{:?}", mask.art.kind); +} + /// 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 6d17acd7e..d3e2f0303 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 (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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; 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: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 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 |