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 <noreply@anthropic.com>
This commit is contained in:
parent
03571affbd
commit
1603729ec3
|
|
@ -137,6 +137,9 @@ const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\
|
|||
/// Attributes of every `<mask>`: 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, "</filter>");
|
||||
fid
|
||||
});
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
|
||||
// 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!("<mask id=\"{mid}\" {MASK} {BIG}{data}>"));
|
||||
if let Some(fid) = &inv {
|
||||
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), 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<String>,
|
||||
/// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]).
|
||||
mask_flags: HashMap<String, (bool, bool)>,
|
||||
}
|
||||
|
||||
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
|
||||
/// (clip, invert).
|
||||
fn mask_flags(svg: &str) -> HashMap<String, (bool, bool)> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// `<mask>` → opacity mask (luminance; alpha masks are approximated by their luminance).
|
||||
/// `<mask>` → 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<Box<vectorcraft_doc::OpacityMask>> {
|
||||
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<Arc<Node>> = 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<Node> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//! * `<mask>` 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 `<text>` when fonts are loaded; we don't load a font database (too expensive and
|
||||
//! unavailable on wasm), so `<text>` elements are read directly from the XML as live point-type
|
||||
//! [`TextObject`]s (type on a path for a `<textPath>`) and placed on top of their layer.
|
||||
|
|
|
|||
|
|
@ -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<OpacityMask> {
|
||||
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("<mask id=")).take_while(|l| !l.contains("</mask>")).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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect width="100" height="100" fill="white"/><rect width="50" height="100" fill="black"/></mask>
|
||||
<g mask="url(#m)"><rect width="100" height="100" fill="red"/></g></svg>"#;
|
||||
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));
|
||||
}
|
||||
Loading…
Reference in New Issue