mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 12:21:54 +00:00
Merge pull request #6401 from dotCooCoo/fix/svg-export-mask-regions
SVG export: mask and filter regions cover the art they apply to
This commit is contained in:
@@ -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: None,
|
||||
art_reach: HashMap::new(),
|
||||
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: Option<vectorcraft_doc::SymbolBoxes<'a>>,
|
||||
/// How far each symbol's art paints past its box, by name, once measured for a region.
|
||||
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).
|
||||
|
||||
+148
-16
@@ -2,8 +2,8 @@ use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_vali
|
||||
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.
|
||||
@@ -143,9 +143,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 +357,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 +376,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 +391,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 +405,127 @@ 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, 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, 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. 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,
|
||||
art_reach: &mut HashMap<String, f64>,
|
||||
n: &Node,
|
||||
b: Rect,
|
||||
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 {
|
||||
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;
|
||||
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,
|
||||
};
|
||||
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 +577,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!("<g mask=\"url(#{mid})\">"));
|
||||
self.depth += 1;
|
||||
}
|
||||
@@ -466,16 +596,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 +622,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>");
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -138,3 +139,126 @@ 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 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)))
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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))]);
|
||||
|
||||
@@ -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, 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) |
|
||||
|
||||
Reference in New Issue
Block a user