From bd0515ccc8b75f82b535e7df7e745366b5097569 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 22:16:58 -0800 Subject: [PATCH 1/2] SVG export: mask and filter regions cover the art they apply to Opacity masks, inverted and unclipped masks, and knockout groups were written with mask and filter regions of 200,000 x 200,000 units around the origin. CairoSVG draws each mask into an image of its region's size and stops with "out of memory", so a file with any of them did not open; Firefox drew inverted masks wrong and WebKit drew knockout groups and inverted masks wrong. Each region now covers what the masked or knocked-out art paints: its visual box grown by its raster effects, brush art and members' reach, the mask art's box, and in a scaled symbol instance its strokes and effects at their own size. A mask whose art reaches past its object still comes back whole on import. --- crates/svg/src/export.rs | 13 ++- crates/svg/src/export/nodes.rs | 137 +++++++++++++++++++++++--- crates/svg/tests/it/blend_export.rs | 63 ++++++++++++ crates/svg/tests/it/mask_options.rs | 21 ++++ crates/svg/tests/it/symbols_layers.rs | 25 +++++ docs/file-format-parity.md | 2 +- 6 files changed, 243 insertions(+), 18 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 13c426847..d671522f5 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -98,7 +98,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: brushes: None, knockout: doc.page_knockout, anonymous: false, - knockout_filter: None, + knockout_filters: HashMap::new(), + region_boxes: std::cell::OnceCell::new(), + art_reach: std::cell::RefCell::default(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -255,8 +257,13 @@ struct Writer<'a> { knockout: bool, /// Writing a copy of an object (a knockout mask): no object ids, so they stay unique. anonymous: bool, - /// The filter that paints art black keeping its alpha (knockout masks), once defined. - knockout_filter: Option, + /// The filters that paint art black keeping its alpha (knockout masks), by the region + /// attributes they were defined with. + knockout_filters: HashMap, + /// The document's symbol boxes, measured once for every mask and filter region. + region_boxes: std::cell::OnceCell>, + /// How far each symbol's art paints past its box, by name, once measured for a region. + art_reach: std::cell::RefCell>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index adcd3fd25..b31fa1099 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; @@ -143,9 +145,12 @@ fn pin_to_page(n: &mut Node, m: Affine) { const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; -/// A region covering any artwork (mask and filter extents). +/// The region of a mask or filter over art that has no box to measure: one covering any artwork. const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; +/// How deep [`Writer::paint_reach`] looks into members and symbol art. +const MAX_REACH_DEPTH: u32 = 64; + /// 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. pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; @@ -354,13 +359,18 @@ impl Writer<'_> { return self.node_body(&bare); } let mid = self.fresh_id("mask"); + let mut bare = n.clone(); + bare.extras_mut().mask = None; + // The region covers what the object and the mask art paint: readers draw the mask into an + // image of the region's size. + let region = self.region_attrs(&[&bare, &m.art]); // Mask art is a picture of its own: it takes no part in a knockout group around the object. let knockout = std::mem::take(&mut self.knockout); let art = self.detached(2, |w| w.node(&m.art)); self.knockout = knockout; let inv = m.invert.then(|| { let fid = self.fresh_id("invert"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, ""); self.def(1, ""); fid @@ -368,13 +378,13 @@ impl Writer<'_> { // 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!("")); + self.def(1, &format!("")); if let Some(fid) = &inv { self.def(1, &format!("")); } if !m.clip || m.invert { let c = if m.clip { "black" } else { "white" }; - self.def(1, &format!("")); + self.def(1, &format!("")); } self.defs.push_str(&art); if inv.is_some() { @@ -383,8 +393,6 @@ impl Writer<'_> { self.def(1, ""); self.line(&format!("")); self.depth += 1; - let mut bare = n.clone(); - bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line(""); @@ -399,6 +407,102 @@ impl Writer<'_> { std::mem::replace(&mut self.body, body) } + /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current + /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each + /// side for antialiasing. [`BIG`] when none of them has a box, or when one nests deeper than + /// [`MAX_REACH_DEPTH`]. The art of a symbol instance written in place keeps its strokes and + /// effects at their own size while the instance scales its geometry, so the region also covers + /// the geometry's box, placed, grown by the strokes' and effects' reach unscaled. + fn region_attrs(&self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.get_or_init(|| self.doc.symbol_boxes()); + let mut art_reach = self.art_reach.borrow_mut(); + let mut painted: Option = None; + for n in nodes { + let Some(v) = n.visual_bounds_with(symbols) else { continue }; + let Some(r) = self.paint_reach(symbols, &mut art_reach, n, v, 0) else { + return BIG.to_string(); + }; + let g = n.geometric_bounds_with(symbols).unwrap_or(v); + let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max); + let scaled = self.xf.transform_rect_bbox(v.inflate(r, r)); + let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r); + let both = scaled.union(unscaled); + painted = Some(painted.map_or(both, |p| p.union(both))); + } + let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else { + return BIG.to_string(); + }; + format!("x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())) + } + + /// How far what `n` paints reaches past its visual box `b`: the reach of its own raster effects + /// and of those of its fills and strokes (measured as [`Self::open_filters`] measures them), + /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed + /// stroke `w`, as the canvas allows for it), and the most that one of its members or its + /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each + /// symbol's art reach by name once measured, so art used many times is measured once. `None` + /// when members or symbol art nest deeper than [`MAX_REACH_DEPTH`]. + fn paint_reach( + &self, + symbols: &vectorcraft_doc::SymbolBoxes, + art_reach: &mut HashMap, + n: &Node, + b: Rect, + depth: u32, + ) -> Option { + use vectorcraft_effects::RasterFx; + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { + vectorcraft_effects::raster_effects(effects) + .iter() + .map(|f| f.outset(b) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }) + .sum() + }; + let items = n.appearance.items.iter().map(|i| reach(i.effects())).fold(0.0, f64::max); + let brush = if vectorcraft_brush::has_brush(n) { + let w = + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + 16.0 * w + 8.0 + } else { + 0.0 + }; + let own = reach(&n.appearance.effects) + items + brush; + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), + _ => None, + }; + if n.children().is_none_or(|c| c.is_empty()) && symbol.is_none() { + return Some(own); + } + if depth >= MAX_REACH_DEPTH { + return None; + } + let mut inner: f64 = 0.0; + for c in n.children().into_iter().flatten() { + if let Some(cb) = c.visual_bounds_with(symbols) { + inner = inner.max(self.paint_reach(symbols, art_reach, c, cb, depth + 1)?); + } + } + if let Some((s, xf)) = symbol { + let art = match art_reach.get(&s.name) { + Some(r) => *r, + None => { + let r = match s.art.visual_bounds_with(symbols) { + Some(ab) => self.paint_reach(symbols, art_reach, &s.art, ab, depth + 1)?, + None => 0.0, + }; + art_reach.insert(s.name.clone(), r); + r + } + }; + // The art's reach, measured in symbol space, as far as the instance can stretch it. + let [xx, xy, yx, yy, _, _] = xf.as_coeffs(); + inner = inner.max(art * xx.hypot(xy).max(yx.hypot(yy)).max(1.0)); + } + Some(own + inner) + } + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. fn group_props(&self, n: &Node) -> Props { let mut p = css::transparency(n); @@ -450,9 +554,12 @@ impl Writer<'_> { written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c)))); } let after = self.symbol_nodes; + // Each mask covers what the group's elements paint. + let all: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let region = self.region_attrs(&all); for (c, (before, _)) in rest.iter().zip(&written).rev() { self.symbol_nodes = *before; - let mid = self.knockout_mask(c); + let mid = self.knockout_mask(c, ®ion); self.line(&format!("")); self.depth += 1; } @@ -466,16 +573,18 @@ impl Writer<'_> { } /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). Returns the mask id. - fn knockout_mask(&mut self, c: &Node) -> String { - let filter = match &self.knockout_filter { + /// opacity without its own mask, unless those define its shape). `region` is the attributes of + /// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares. + /// Returns the mask id. + fn knockout_mask(&mut self, c: &Node, region: &str) -> String { + let filter = match self.knockout_filters.get(region) { Some(f) => f.clone(), None => { let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, ""); self.def(1, ""); - self.knockout_filter = Some(f.clone()); + self.knockout_filters.insert(region.to_string(), f.clone()); f } }; @@ -490,8 +599,8 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; let mid = self.fresh_id("knockout"); - self.def(1, &format!("")); - self.def(2, &format!("")); + self.def(1, &format!("")); + self.def(2, &format!("")); self.def(2, &format!("")); self.defs.push_str(&art); self.def(2, ""); diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 65fb26799..0109897ca 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -138,3 +138,66 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { let svg = export(&d, &ExportOptions::default()); assert!(svg.contains("` and `` element in `svg`. +fn regions(svg: &str) -> Vec<[f64; 4]> { + let opts = resvg::usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let xml = resvg::usvg::roxmltree::Document::parse_with_options(svg, opts).unwrap(); + xml.descendants() + .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) + .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) + .collect() +} + +/// Mask and filter regions cover the art they apply to and no more: readers draw a mask into an +/// image of its region's size, and one drawn at 200,000 units square runs out of memory. +#[test] +fn mask_and_filter_regions_cover_their_art() { + let knockout = { + let mut d = Document::new(40.0, 20.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [200, 0, 0]); + let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("clip", masked(true, false)), ("no clip, inverted", masked(false, true)), ("knockout", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + // The art is 40 × 20, with 2 to spare on each side. + assert!(x >= -2.5 && y >= -2.5 && w <= 45.0 && h <= 25.0 && w >= 40.0 && h >= 20.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A mask's region reaches as far as the masked art paints: a drop shadow of the object, or of an +/// object in the masked group, shows past the object as on the canvas. +#[test] +fn a_mask_region_keeps_the_shadows_of_the_masked_art() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + for in_group in [false, true] { + let mut d = Document::new(60.0, 60.0); + let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]); + red.appearance.effects.push(shadow.clone()); + let mut masked = if in_group { Node::group(d.alloc_id(), vec![red.into()]) } else { red }; + // An unclipped mask lets the art show outside the mask art: all of the shadow shows. + 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()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 60, 60)); + for (x, y) in [(15, 15), (40, 40), (35, 28)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(within(c, w, 3), "in a group {in_group}, at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } + assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); + } +} diff --git a/crates/svg/tests/it/mask_options.rs b/crates/svg/tests/it/mask_options.rs index 541a8f702..16c021391 100644 --- a/crates/svg/tests/it/mask_options.rs +++ b/crates/svg/tests/it/mask_options.rs @@ -55,6 +55,27 @@ fn an_inverted_unclipped_mask_round_trips() { } } +/// Mask art that reaches past the masked object comes back whole: the mask's region covers the art +/// as well as the object, so import does not clip the art to the region. +#[test] +fn mask_art_larger_than_its_object_round_trips_unclipped() { + 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(50.0, 50.0, 100.0, 100.0)), red); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 20.0, 180.0, 170.0)), white); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let s = export(&d, &ExportOptions::default()); + let (back, warnings) = import_with_report(&s).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") }; + assert!(m.art.path_data().is_some(), "the art alone, not clipped by the region: {:?}\n{s}", m.art.kind); + let b = m.art.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 170.0).abs() < 1e-6, "{b:?}"); +} + #[test] fn foreign_masks_keep_their_art() { // Without the attribute a white backdrop is part of the mask art (another app's file). diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 0fad6ff09..3e58b0228 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -296,6 +296,31 @@ fn masks_in_symbols_move_with_the_art_when_linked() { } } +/// A masked object in a symbol's art, drawn by an instance that scales it down: its stroke keeps +/// its weight, and the mask's region still covers all of it. +#[test] +fn a_mask_in_a_scaled_instance_covers_the_strokes_at_their_weight() { + let mut d = Document::new(120.0, 120.0); + let stroked = Appearance::basic(Paint::solid(Color::rgb8(200, 0, 0)), Paint::solid(Color::BLACK), 20.0); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), stroked); + let shape = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(shape, false))); + d.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(art) }); + instance(&mut d, "Framed", Affine::translate((50.0, 50.0)) * Affine::scale(0.2)); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 120, 120)); + // The stroke runs 10 either side of the 20 × 20 square's edge at (50, 50). + for (x, y) in [(43, 60), (77, 60), (60, 43), (60, 77)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(c.iter().all(|v| *v < 60), "the stroke shows on the canvas at {x},{y}: {c:?}"); + assert!(c.iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 8), "at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } +} + #[test] fn symbols_inside_themselves_end() { let mut d = symbol_doc(1.0, &[Affine::translate((50.0, 50.0))]); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10dded623..ad63dbe5f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | 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 Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; 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` | -| 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`) | +| 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 (`crates/svg/tests/it/blend_export.rs`) | | 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). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | From 8ae9ca2012fc3b6113e6ad21c5adc919329555ff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 22:37:17 -0800 Subject: [PATCH 2/2] SVG export: masks over type keep the full region; live art measured expanded A mask, inverted mask or knockout group over type now keeps the region that covers any artwork. Type is measured by its layout or, on a path, by the path, and a reader that sets live type in its own font can draw it wider, so a region sized from that box could cut glyphs off. A live object (blend, envelope, repeat, mesh, placed document, intertwine) under a mask is measured as the art the writer writes for it, expanded on its own with the same limits. The writer keeps its symbol boxes and symbols' art reach in plain fields instead of cells. --- crates/svg/src/export.rs | 8 ++-- crates/svg/src/export/nodes.rs | 51 +++++++++++++++------ crates/svg/tests/it/blend_export.rs | 69 +++++++++++++++++++++++++++-- docs/file-format-parity.md | 2 +- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index d671522f5..eed8ecd33 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -99,8 +99,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: knockout: doc.page_knockout, anonymous: false, knockout_filters: HashMap::new(), - region_boxes: std::cell::OnceCell::new(), - art_reach: std::cell::RefCell::default(), + region_boxes: None, + art_reach: HashMap::new(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -261,9 +261,9 @@ struct Writer<'a> { /// attributes they were defined with. knockout_filters: HashMap<String, String>, /// The document's symbol boxes, measured once for every mask and filter region. - region_boxes: std::cell::OnceCell<vectorcraft_doc::SymbolBoxes<'a>>, + region_boxes: Option<vectorcraft_doc::SymbolBoxes<'a>>, /// How far each symbol's art paints past its box, by name, once measured for a region. - art_reach: std::cell::RefCell<HashMap<String, f64>>, + art_reach: HashMap<String, f64>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option<Vec<vectorcraft_brush::Brush>>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index b31fa1099..129bd83e2 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,11 +1,9 @@ -use std::collections::HashMap; - use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; use super::{ - Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, Writer, - Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, + Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, HashMap, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, + Writer, Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, }; /// A symbol's `<symbol>` def and the instance transforms that can `<use>` it. @@ -409,17 +407,27 @@ impl Writer<'_> { /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each - /// side for antialiasing. [`BIG`] when none of them has a box, or when one nests deeper than - /// [`MAX_REACH_DEPTH`]. The art of a symbol instance written in place keeps its strokes and - /// effects at their own size while the instance scales its geometry, so the region also covers - /// the geometry's box, placed, grown by the strokes' and effects' reach unscaled. - fn region_attrs(&self, nodes: &[&Node]) -> String { - let symbols = self.region_boxes.get_or_init(|| self.doc.symbol_boxes()); - let mut art_reach = self.art_reach.borrow_mut(); + /// side for antialiasing. [`BIG`] when none of them has a box, when one holds type (a reader + /// sets live type in its own font, which can be wider, and type on a path is measured by its + /// path), or when one nests deeper than [`MAX_REACH_DEPTH`]. The art of a symbol instance + /// written in place keeps its strokes and effects at their own size while the instance scales + /// its geometry, so the region also covers the geometry's box, placed, grown by the strokes' + /// and effects' reach unscaled. + fn region_attrs(&mut self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes()); + let mut art_reach = std::mem::take(&mut self.art_reach); + let region = self.region_of(&symbols, &mut art_reach, nodes); + self.region_boxes = Some(symbols); + self.art_reach = art_reach; + region + } + + /// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far. + fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, nodes: &[&Node]) -> String { let mut painted: Option<Rect> = None; for n in nodes { let Some(v) = n.visual_bounds_with(symbols) else { continue }; - let Some(r) = self.paint_reach(symbols, &mut art_reach, n, v, 0) else { + let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else { return BIG.to_string(); }; let g = n.geometric_bounds_with(symbols).unwrap_or(v); @@ -440,8 +448,10 @@ impl Writer<'_> { /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed /// stroke `w`, as the canvas allows for it), and the most that one of its members or its /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each - /// symbol's art reach by name once measured, so art used many times is measured once. `None` - /// when members or symbol art nest deeper than [`MAX_REACH_DEPTH`]. + /// symbol's art reach by name once measured, so art used many times is measured once. A live + /// object is measured as the art it is written as: expanded on its own, as [`Self::node`] + /// expands it, so with the same limits. `None` when `n`, its members or its symbol's art hold + /// type, or nest deeper than [`MAX_REACH_DEPTH`]. fn paint_reach( &self, symbols: &vectorcraft_doc::SymbolBoxes, @@ -451,6 +461,10 @@ impl Writer<'_> { depth: u32, ) -> Option<f64> { use vectorcraft_effects::RasterFx; + // Type draws past its measured box in a reader's own font. + if matches!(n.kind, NodeKind::Text(_)) { + return None; + } // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. let grow = 18.0 / self.doc.raster_ppi(); let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { @@ -468,6 +482,15 @@ impl Writer<'_> { 0.0 }; let own = reach(&n.appearance.effects) + items + brush; + if vectorcraft_doc::live::is_live(n) { + if depth >= MAX_REACH_DEPTH { + return None; + } + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + let Some(gb) = g.visual_bounds_with(symbols) else { return Some(own) }; + let past = [b.x0 - gb.x0, b.y0 - gb.y0, gb.x1 - b.x1, gb.y1 - b.y1].into_iter().fold(0.0, f64::max); + return Some(own + past + self.paint_reach(symbols, art_reach, &g, gb, depth + 1)?); + } let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), _ => None, diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 0109897ca..3804d30ed 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -1,10 +1,11 @@ //! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the //! reference formulas when resvg draws the file, and masks take luminance the canvas's way. +use usvg::roxmltree; use vectorcraft_color::blend::blend_rgb; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, OpacityMask, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; use vectorcraft_svg::{ExportOptions, export}; use vectorcraft_testkit::raster::{Image, render_artboard}; @@ -141,8 +142,7 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { /// The `x`, `y`, `width` and `height` of each `<mask>` and `<filter>` element in `svg`. fn regions(svg: &str) -> Vec<[f64; 4]> { - let opts = resvg::usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; - let xml = resvg::usvg::roxmltree::Document::parse_with_options(svg, opts).unwrap(); + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); xml.descendants() .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) @@ -201,3 +201,64 @@ fn a_mask_region_keeps_the_shadows_of_the_masked_art() { assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); } } + +/// 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] +fn mask_and_filter_regions_over_type_cover_any_artwork() { + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let text = |d: &mut Document| Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style.clone())))); + let masked = { + let mut d = Document::new(80.0, 40.0); + let mut t = text(&mut d); + let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]); + t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, t); + d + }; + let knockout = { + let mut d = Document::new(80.0, 40.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 80.0, 40.0), [200, 0, 0]); + let mut top = text(&mut d); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("masked type", masked), ("type in a knockout group", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + assert!(x <= -100000.0 && y <= -100000.0 && w >= 200000.0 && h >= 200000.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A live object under a mask is measured as it is written, expanded: the mask's region covers +/// the drop shadows of a blend's steps. +#[test] +fn a_mask_region_covers_a_blend_as_written() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + let mut d = Document::new(100.0, 50.0); + let mut a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), [255, 0, 0]); + let mut b = rect(&mut d, Rect::new(70.0, 10.0, 80.0, 20.0), [0, 0, 255]); + a.appearance.effects.push(shadow.clone()); + b.appearance.effects.push(shadow); + let mut blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![a.into(), b.into()], spec: Default::default() }); + let art = rect(&mut d, Rect::new(0.0, 0.0, 5.0, 5.0), [255, 255, 255]); + blend.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, blend); + let svg = export(&d, &ExportOptions::default()); + // The steps' drop shadows are filters with regions of their own; the mask's covers them all: + // the steps run from 10 to 80 across, and their shadows 15 further. + let xml = roxmltree::Document::parse_with_options(&svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + let mask = xml.descendants().find(|n| n.has_tag_name("mask")).unwrap(); + let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN)); + assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad63dbe5f..b1c305db1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | 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 Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; 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` | -| 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 (`crates/svg/tests/it/blend_export.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`) | | 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). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |