SVG import: filter and mask regions clip what they declare (#5876, #5875)

Squashed from #5913 onto main.
This commit is contained in:
Don Omondi
2026-11-06 16:39:04 +07:00
committed by bflatastic
parent 9195f43901
commit f08381df8d
3 changed files with 119 additions and 3 deletions
+60 -1
View File
@@ -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
/// (<https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion>) 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<Node> {
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<Node> {
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 })
}
/// `<mask>` → 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))
+57 -1
View File
@@ -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<Effect> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><filter id="f" filterUnits="userSpaceOnUse" x="0" y="0" width="{width}" height="200"><feGaussianBlur stdDeviation="5"/></filter></defs><rect width="200" height="200" fill="red" filter="url(#f)"/></svg>"##
)
};
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##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><filter id="f" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="200"><feGaussianBlur stdDeviation="2"/></filter></defs><rect x="50" y="50" width="100" height="100" fill="red" filter="url(#f)"/></svg>"##;
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##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><mask id="m" {region}><rect width="200" height="200" fill="white"/></mask></defs><rect width="200" height="200" fill="red" mask="url(#m)"/></svg>"##
)
};
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));
+2 -1
View File
@@ -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 |