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, /// The document's symbol boxes, measured once for every mask and filter region. - region_boxes: std::cell::OnceCell>, + region_boxes: Option>, /// How far each symbol's art paints past its box, by name, once measured for a region. - art_reach: std::cell::RefCell>, + art_reach: HashMap, /// 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 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 `` def and the instance transforms that can `` 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, nodes: &[&Node]) -> String { 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 { + 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 { 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 `` 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(); + 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::().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 ``, 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) |