From 8ae9ca2012fc3b6113e6ad21c5adc919329555ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 22:37:17 -0800 Subject: [PATCH] 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, /// 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) |