diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs
index 8716af7367..ea1d5ab95b 100644
--- a/crates/svg/src/export/nodes.rs
+++ b/crates/svg/src/export/nodes.rs
@@ -431,11 +431,75 @@ impl Writer<'_> {
self.def(1, "");
self.line(&format!(""));
self.depth += 1;
+ self.spanning_rect(&bare, ®ion);
self.node_body(&bare);
self.depth -= 1;
self.line("");
}
+ /// 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
+ /// ``, 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!(""));
+ }
+ }
+
+ /// 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> = vec![];
+ let mut symbols: std::collections::HashSet<(String, Option)> = 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>| {
+ 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 ``).
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, ®ion);
self.line(&format!(""));
+ // 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, ®ion);
+ self.depth -= 1;
self.body.push_str(&body);
self.line("");
}
diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs
index dd86f318f5..ec947041b0 100644
--- a/crates/svg/tests/it/blend_export.rs
+++ b/crates/svg/tests/it/blend_export.rs
@@ -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 `` 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 `` 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 {
+ let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
+ let spans: Vec = 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]
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index a211c0ca53..a5ff38be13 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -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 ``, 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 ``, ``, ``, ``, `` and `` 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 ``, 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 ``, ``, ``, ``, `` and `` 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 |