Merge pull request #7436 from dotCooCoo/fix/svg-masked-shadow

SVG export: shadows in a masked group show in readers that bound it by its shapes
This commit is contained in:
BFlat
2026-11-14 21:48:10 +07:00
committed by GitHub
3 changed files with 277 additions and 2 deletions
+70 -1
View File
@@ -431,11 +431,75 @@ impl Writer<'_> {
self.def(1, "</mask>");
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
self.spanning_rect(&bare, &region);
self.node_body(&bare);
self.depth -= 1;
self.line("</g>");
}
/// Some readers draw a masked group only inside the bounding box of its shapes, which leaves
/// out a drop shadow, blur, glow or feather that paints past them. When `n`, written inside a
/// `<g mask>`, holds one ([`Self::holds_raster`]), a hidden, unpainted rectangle over the
/// mask's `region` makes that box cover what it paints.
fn spanning_rect(&mut self, n: &Node, region: &str) {
if self.holds_raster(n) {
self.line(&format!("<rect {region} fill=\"none\" visibility=\"hidden\"/>"));
}
}
/// Do `n`, its fills and strokes, its members at any depth, its symbols' art or the art its
/// live objects are written as have a visible raster effect (shadow, glow, blur, feather)?
/// Objects that aren't written (hidden ones, unless the options keep them) are skipped, and so
/// are fills and strokes that [`Self::shape`] doesn't write (hidden, unpainted or zero-width).
/// An instance with overrides is looked at in the art it is written as
/// ([`vectorcraft_brush::instance_art`]). The walk keeps its own stack, and looks at each
/// symbol's art once, and at each instance's overridden art once.
fn holds_raster(&self, n: &Node) -> bool {
let mut stack: Vec<std::sync::Arc<Node>> = vec![];
let mut symbols: std::collections::HashSet<(String, Option<vectorcraft_doc::NodeId>)> = std::collections::HashSet::new();
let paints = |i: &AppearanceItem| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
};
// Whether `n` itself has one; else its members, symbol art or expanded art go on the stack.
let mut visit = |n: &Node, stack: &mut Vec<std::sync::Arc<Node>>| {
if !(n.visible || self.opts.hidden_layers) {
return false;
}
if has_raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| paints(i) && has_raster(i.effects())) {
return true;
}
// A live object is written as its expanded art ([`Self::node_body`]), which holds no
// live objects.
if vectorcraft_doc::live::is_live(n) {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
if !vectorcraft_doc::live::is_live(&g) {
stack.push(std::sync::Arc::new(g));
return false;
}
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
let custom = !n.extras().symbol_overrides.is_empty();
if symbols.insert((symbol.clone(), custom.then_some(n.id)))
&& let Some(s) = self.doc.symbols.iter().find(|s| s.name == *symbol)
{
stack.push(if custom { std::sync::Arc::new(vectorcraft_brush::instance_art(&s.art, n)) } else { s.art.clone() });
}
}
stack.extend(n.children().into_iter().flatten().cloned());
false
};
if visit(n, &mut stack) {
return true;
}
while let Some(c) = stack.pop() {
if visit(&c, &mut stack) {
return true;
}
}
false
}
/// What `write` writes to the body, taken out of it (written at `depth`, for `<defs>`).
fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String {
let (body, saved) = (std::mem::take(&mut self.body), self.depth);
@@ -664,7 +728,7 @@ impl Writer<'_> {
}
}
self.symbol_nodes = after;
for (above, (_, body)) in above.iter().zip(written) {
for ((c, above), (_, body)) in elements.iter().zip(&above).zip(written) {
if above.is_empty() {
self.body.push_str(&body);
continue;
@@ -672,6 +736,11 @@ impl Writer<'_> {
let ids: Vec<&str> = above.iter().filter_map(|&j| shapes[j].as_deref()).collect();
let mid = self.knockout_mask(&ids, &region);
self.line(&format!("<g mask=\"url(#{mid})\">"));
// An element that paints past its shapes starts its mask group with a spanning
// rectangle (`Self::spanning_rect`).
self.depth += 1;
self.spanning_rect(c, &region);
self.depth -= 1;
self.body.push_str(&body);
self.line("</g>");
}
+206
View File
@@ -202,6 +202,212 @@ fn a_mask_region_keeps_the_shadows_of_the_masked_art() {
}
}
fn drop_shadow() -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
visible: true,
}
}
/// Whether every `<g mask>` in `svg` starts with a hidden, unpainted rectangle over its mask's region,
/// and whether any does.
fn spanning_rects(svg: &str) -> (bool, bool) {
let spans = masked_group_spans(svg);
(spans.iter().all(|s| *s), spans.iter().any(|s| *s))
}
/// For each `<g mask>` in `svg`, in document order, whether it starts with a hidden, unpainted
/// rectangle over its mask's region.
fn masked_group_spans(svg: &str) -> Vec<bool> {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
let spans: Vec<bool> = xml
.descendants()
.filter(|n| n.has_tag_name("g") && n.has_attribute("mask"))
.map(|group| {
let id = group.attribute("mask").unwrap().trim_start_matches("url(#").trim_end_matches(')');
let mask = xml.descendants().find(|n| n.has_tag_name("mask") && n.attribute("id") == Some(id)).unwrap();
let first = group.children().find(|n| n.is_element()).unwrap();
first.has_tag_name("rect")
&& first.attribute("fill") == Some("none")
&& first.attribute("visibility") == Some("hidden")
&& ["x", "y", "width", "height"].iter().all(|a| first.attribute(*a).is_some() && first.attribute(*a) == mask.attribute(*a))
})
.collect();
assert!(!spans.is_empty(), "no masked group in {svg}");
spans
}
/// The number of paths `svg` imports as.
fn imported_paths(svg: &str) -> usize {
let (back, warnings) = vectorcraft_svg::import_with_report(svg).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let mut paths = 0;
back.walk(|n| paths += usize::from(n.path_data().is_some()));
paths
}
/// A masked group whose content holds a raster effect (a drop shadow, blur, glow or feather, which
/// paint past the shapes) starts with a hidden, unpainted rectangle over the mask's region, so that
/// a reader that draws a masked group only inside the box of its shapes still draws it. Art without
/// one gets none, brushed strokes (written as shapes) included, nor does an object whose own shadow
/// is drawn outside the mask. Import leaves the rectangle out.
#[test]
fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
// How the red square sits under the mask.
#[derive(Debug, Clone, Copy)]
enum Case {
Alone,
InGroup,
InNestedGroup,
FillEffect,
HiddenFill,
UnpaintedStroke,
HiddenInGroup,
Brushed,
InSymbol,
OverriddenSymbol,
InBlend,
}
use Case::*;
for (case, shadowed, want) in [
(InGroup, true, true),
(InGroup, false, false),
(Alone, true, false),
(Alone, false, false),
(InNestedGroup, true, true),
(FillEffect, true, true),
(FillEffect, false, false),
// A fill or stroke that isn't written doesn't count.
(HiddenFill, true, false),
(UnpaintedStroke, true, false),
(HiddenInGroup, true, false),
(Brushed, false, false),
(Brushed, true, true),
(InSymbol, true, true),
(InSymbol, false, false),
// An unpainted fill in a dynamic symbol's art that the instance paints counts.
(OverriddenSymbol, true, true),
(OverriddenSymbol, false, false),
(InBlend, true, true),
(InBlend, false, false),
] {
let mut d = Document::new(100.0, 60.0);
let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]);
if matches!(case, Brushed) {
let mut stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 200)), 8.0);
stroke.brush = Some("Bristle Round".into());
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(stroke));
}
if matches!(case, UnpaintedStroke) {
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::None, 8.0)));
}
if shadowed {
match case {
FillEffect | HiddenFill | OverriddenSymbol => red.appearance.items[0].effects_mut().push(drop_shadow()),
UnpaintedStroke => red.appearance.items.last_mut().unwrap().effects_mut().push(drop_shadow()),
_ => red.appearance.effects.push(drop_shadow()),
}
}
if matches!(case, HiddenFill)
&& let vectorcraft_doc::AppearanceItem::Fill(f) = &mut red.appearance.items[0]
{
f.visible = false;
}
if matches!(case, OverriddenSymbol) {
red.appearance.set_fill(Paint::None);
}
let group = |d: &mut Document, n: Node| Node::group(d.alloc_id(), vec![n.into()]);
let mut masked = match case {
Alone | FillEffect => red,
HiddenFill | UnpaintedStroke => group(&mut d, red),
InGroup | Brushed => group(&mut d, red),
InNestedGroup => {
let inner = group(&mut d, red);
group(&mut d, inner)
}
HiddenInGroup => {
red.visible = false;
group(&mut d, red)
}
InSymbol => {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Square".into(), art: std::sync::Arc::new(red) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
group(&mut d, instance)
}
OverriddenSymbol => {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Square".into(), art: std::sync::Arc::new(red) });
let mut instance =
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let fill = Some(Paint::solid(Color::rgb8(255, 0, 0)));
instance.extras_mut().symbol_overrides.push(vectorcraft_doc::symbol_overrides::SymbolOverride { fill, ..Default::default() });
group(&mut d, instance)
}
InBlend => {
let mut blue = rect(&mut d, Rect::new(60.0, 10.0, 80.0, 30.0), [0, 0, 255]);
blue.appearance.effects = red.appearance.effects.clone();
let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![red.into(), blue.into()], spec: Default::default() });
group(&mut d, blend)
}
};
let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, masked);
let svg = export(&d, &ExportOptions::default());
assert_eq!(spanning_rects(&svg).0, want, "{case:?}, shadowed {shadowed}:\n{svg}");
if matches!(case, Alone | InGroup | InNestedGroup | FillEffect) {
// The red square alone: no rectangle of the region's size comes back.
assert_eq!(imported_paths(&svg), 1, "{case:?}, shadowed {shadowed}:\n{svg}");
}
}
// Type can't be measured, so its mask's region covers any artwork: a group of type gets the
// rectangle only when it holds a raster effect, on the type or on a path beside it.
for (shadowed, path_shadowed) in [(false, false), (true, false), (false, true)] {
let mut d = Document::new(80.0, 40.0);
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style))));
if shadowed {
t.appearance.effects.push(drop_shadow());
}
let mut red = rect(&mut d, Rect::new(50.0, 5.0, 60.0, 15.0), [255, 0, 0]);
if path_shadowed {
red.appearance.effects.push(drop_shadow());
}
let mut group = Node::group(d.alloc_id(), vec![t.into(), red.into()]);
let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]);
group.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
let want = shadowed || path_shadowed;
assert_eq!(svg.contains("visibility=\"hidden\""), want, "type {shadowed}, path {path_shadowed}:\n{svg}");
}
}
/// In a knockout group, each element is drawn inside one mask of the elements above it. When the
/// element inside a mask's group has a drop shadow, that group starts with the same hidden,
/// unpainted rectangle over the region, the other groups don't, and import leaves it out.
#[test]
fn a_knockout_group_spans_its_masks_when_an_element_paints_past_its_shapes() {
for shadowed in [false, true] {
let mut d = Document::new(60.0, 60.0);
let mut below = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), [200, 0, 0]);
if shadowed {
below.appearance.effects.push(drop_shadow());
}
let mut middle = rect(&mut d, Rect::new(15.0, 15.0, 35.0, 35.0), [0, 200, 0]);
middle.opacity = 0.5;
let mut top = rect(&mut d, Rect::new(20.0, 5.0, 30.0, 40.0), [0, 0, 200]);
top.opacity = 0.5;
let mut group = Node::group(d.alloc_id(), vec![below.into(), middle.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
// The bottom and middle elements are masked, in paint order; the top one isn't.
assert_eq!(masked_group_spans(&svg), [shadowed, false], "shadowed {shadowed}:\n{svg}");
assert_eq!(imported_paths(&svg), 3, "shadowed {shadowed}:\n{svg}");
}
}
/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a
/// mask or knockout over type keeps the region that covers any artwork.
#[test]
+1 -1
View File
@@ -285,7 +285,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
| DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); a masked group, or a knockout group's masked group, whose content has a shadow, glow, blur or feather starts with a hidden, unpainted rectangle over the mask's region, so readers that draw a masked group only inside the box of its shapes draw those effects too (`crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) |
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open |
| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` |
| DWG | open, export | no | no | no open spec; DXF instead |