From 1603729ec3643eb8a4d291e705e09b606ae9bb36 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:46:21 +0700 Subject: [PATCH] M3.87: SVG opacity masks keep their Clip and Invert options Exported masks carry data-vectorcraft-mask="noclip invert" (the options that differ from clipping, not inverted). Import restores the flags and the original mask art, dropping the backdrop rectangle and inverting filter group that draw the options in SVG; a single mask object comes back as itself, not wrapped in a group. Masks from other apps import as before. Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/export.rs | 8 +++- crates/svg/src/import.rs | 51 ++++++++++++++++++++---- crates/svg/src/lib.rs | 5 ++- crates/svg/tests/mask_options.rs | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 crates/svg/tests/mask_options.rs diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index fcce2df5..282a9946 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -137,6 +137,9 @@ const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\ /// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the /// canvas and PDF take it) rather than from linearised ones. const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; +/// Attribute of an exported opacity mask listing its options that differ from the defaults +/// (clipping, not inverted): `noclip`, `invert`. +pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; pub(crate) fn blend_css(b: BlendMode) -> &'static str { match b { @@ -662,7 +665,10 @@ impl Writer<'_> { self.def(1, ""); fid }); - self.def(1, &format!("")); + // The mask's options, so import restores them (and its art without backdrop and filter). + let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); + let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; + self.def(1, &format!("")); if let Some(fid) = &inv { self.def(1, &format!("")); } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index dfeea2c6..3b9ee12e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -22,7 +22,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { // absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt). let (kx, ky) = physical_scale(svg); let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); - let mut im = Importer { doc, warnings: Vec::new() }; + let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg) }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); @@ -85,6 +85,25 @@ fn physical_scale(svg: &str) -> (f64, f64) { struct Importer { doc: Document, warnings: Vec, + /// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]). + mask_flags: HashMap, +} + +/// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: +/// (clip, invert). +fn mask_flags(svg: &str) -> HashMap { + let attr = crate::export::MASK_FLAGS; + if !svg.contains(attr) { + return HashMap::new(); + } + let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { + return HashMap::new(); + }; + let flags = |n: roxmltree::Node| { + let has = |f: &str| n.attribute(attr).is_some_and(|v| v.split_whitespace().any(|x| x == f)); + Some((n.attribute("id")?.to_string(), (!has("noclip"), has("invert")))) + }; + xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() } fn aff(t: usvg::Transform) -> Affine { @@ -223,7 +242,9 @@ impl Importer { Some(n) } - /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). + /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). A + /// mask our export wrote gets its options back ([`mask_flags`]) and its art without what + /// stands for them: the inverting filter group and the backdrop rectangle. fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option> { if m.kind() == usvg::MaskType::Alpha { self.warn(format!("alpha mask on {label} imported as a luminance opacity mask")); @@ -231,12 +252,28 @@ impl Importer { if m.mask().is_some() { self.warn(format!("nested mask on {label} ignored")); } - let children = self.children(m.root(), ts); - if children.is_empty() { - return None; + let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false)); + let (mut nodes, mut ts) = (m.root().children(), ts); + if invert + && let [usvg::Node::Group(g)] = nodes + && !g.filters().is_empty() + { + (nodes, ts) = (g.children(), ts * aff(g.transform())); } - let art = self.named("", NodeKind::Group { children, clip: false }); - Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))) + if (!clip || invert) + && let [usvg::Node::Path(_), rest @ ..] = nodes + { + nodes = rest; + } + let mut children: Vec> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect(); + let art = match children.len() { + 0 => return None, + 1 => Arc::unwrap_or_clone(children.pop()?), + _ => self.named("", NodeKind::Group { children, clip: false }), + }; + let mut mask = vectorcraft_doc::OpacityMask::new(art, clip); + mask.invert = invert; + Some(Box::new(mask)) } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 15aba18c..6aaa7623 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -27,7 +27,10 @@ //! * One CSS pixel (user unit) is one point, as on export; a root `width`/`height` in absolute units //! (`in`, `cm`, `mm`, `pt`, `pc`) keeps its physical size (72 pt per inch), so a 210 mm SVG opens on //! a 210 mm artboard. -//! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only. +//! * `` imports as a luminance opacity mask; a mask we exported keeps its options and art +//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and +//! not inverted). Filters are ignored (reported as warnings); nested clip paths use the outer +//! clip only. //! * usvg only keeps `` when fonts are loaded; we don't load a font database (too expensive and //! unavailable on wasm), so `` elements are read directly from the XML as live point-type //! [`TextObject`]s (type on a path for a ``) and placed on top of their layer. diff --git a/crates/svg/tests/mask_options.rs b/crates/svg/tests/mask_options.rs new file mode 100644 index 00000000..fd310d11 --- /dev/null +++ b/crates/svg/tests/mask_options.rs @@ -0,0 +1,67 @@ +//! Opacity-mask options survive SVG (M3.87): exported masks list the options that differ from the +//! defaults in `data-vectorcraft-mask`, and import restores them and the original mask art (without +//! the backdrop rectangle and inverting filter that draw the options in SVG). + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export, import_with_report}; + +/// A red square masked by white art over its left half. +fn masked(clip: bool, invert: bool) -> Document { + let mut d = Document::new(200.0, 200.0); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), red); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white); + let mut m = OpacityMask::new(art, clip); + m.invert = invert; + n.mask = Some(Box::new(m)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn masks(d: &Document) -> Vec { + let mut v = vec![]; + d.walk(|n| v.extend(n.mask.as_deref().cloned())); + v +} + +#[test] +fn an_inverted_unclipped_mask_round_trips() { + for (clip, invert, attr) in + [(false, true, Some("noclip invert")), (true, true, Some("invert")), (false, false, Some("noclip")), (true, false, None)] + { + let s = export(&masked(clip, invert), &ExportOptions::default()); + match attr { + Some(a) => assert!(s.contains(&format!("data-vectorcraft-mask=\"{a}\"")), "{s}"), + None => assert!(!s.contains("data-vectorcraft-mask"), "{s}"), + } + let (back, warnings) = import_with_report(&s).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") }; + assert_eq!((m.clip, m.invert), (clip, invert), "{s}"); + // The original art: one white rectangle, no backdrop and no filter group around it. + assert!(m.art.path_data().is_some(), "{:?}", m.art.kind); + let b = m.art.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.x1 - 50.0).abs() < 1e-6 && (b.y0 - 10.0).abs() < 1e-6 && (b.y1 - 90.0).abs() < 1e-6, "{b:?}"); + assert_eq!(m.art.appearance.fill_paint(), Paint::solid(Color::WHITE)); + // Exporting the import again writes the same mask. + let again = export(&back, &ExportOptions::default()); + let mask_def = |s: &str| s.lines().skip_while(|l| !l.contains("")).count(); + assert_eq!(mask_def(&again), mask_def(&s), "{again}\n{s}"); + } +} + +#[test] +fn foreign_masks_keep_their_art() { + // Without the attribute a white backdrop is part of the mask art (another app's file). + let svg = r#" + + "#; + let (d, _) = import_with_report(svg).unwrap(); + let [m] = &masks(&d)[..] else { panic!("one mask") }; + assert!(m.clip && !m.invert); + assert_eq!(m.art.children().map(|c| c.len()), Some(2)); +}