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.
This commit is contained in:
dotCooCoo
2026-11-09 22:16:58 -08:00
parent 520b69efd9
commit bd0515ccc8
6 changed files with 243 additions and 18 deletions
+10 -3
View File
@@ -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<String>,
/// The filters that paint art black keeping its alpha (knockout masks), by the region
/// 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>>,
/// 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>>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Features written approximately (once each).
+123 -14
View File
@@ -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 `<mask>`: 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!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>"));
self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {region}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0\"/>");
self.def(1, "</filter>");
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!("<mask id=\"{mid}\" {MASK} {BIG}{data}>"));
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}{data}>"));
if let Some(fid) = &inv {
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
}
if !m.clip || m.invert {
let c = if m.clip { "black" } else { "white" };
self.def(1, &format!("<rect {BIG} fill=\"{c}\"/>"));
self.def(1, &format!("<rect {region} fill=\"{c}\"/>"));
}
self.defs.push_str(&art);
if inv.is_some() {
@@ -383,8 +393,6 @@ impl Writer<'_> {
self.def(1, "</mask>");
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
let mut bare = n.clone();
bare.extras_mut().mask = None;
self.node_body(&bare);
self.depth -= 1;
self.line("</g>");
@@ -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<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 {
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<String, f64>,
n: &Node,
b: Rect,
depth: u32,
) -> Option<f64> {
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, &region);
self.line(&format!("<g mask=\"url(#{mid})\">"));
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!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {BIG}>"));
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {region}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>");
self.def(1, "</filter>");
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!("<mask id=\"{mid}\" {MASK} {BIG}>"));
self.def(2, &format!("<rect {BIG} fill=\"white\"/>"));
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>"));
self.def(2, &format!("<rect {region} fill=\"white\"/>"));
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
self.defs.push_str(&art);
self.def(2, "</g>");
+63
View File
@@ -138,3 +138,66 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() {
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("<mask"), "{svg}");
}
/// 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();
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));
}
}
+21
View File
@@ -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).
+25
View File
@@ -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))]);
+1 -1
View File
@@ -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`) |
| 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) |