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 `<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 |