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)); +}