mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 04:11:23 +00:00
Squashed from #5913 onto main.
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user