mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
Merge pull request #7273 from dotCooCoo/fix/knockout-groups-flat-masks
PDF/SVG export: knockout groups nest no deeper with more elements
This commit is contained in:
@@ -304,11 +304,22 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap();
|
||||
assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0);
|
||||
// A knockout group is approximated: its warning comes back from every PDF path.
|
||||
// A knockout group is written as a PDF knockout group: nothing to report.
|
||||
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
let knockout = |w: Vec<String>| w.iter().any(|w| w.contains("knockout groups"));
|
||||
assert!(!knockout(warnings(&export(&mut s, json!({})))));
|
||||
// One whose soft masks would draw a brushed stroke is approximated: its warning comes back
|
||||
// from every PDF path.
|
||||
let rect =
|
||||
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
|
||||
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
assert!(knockout(warnings(&export(&mut s, json!({})))));
|
||||
assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap())));
|
||||
// Other formats have none.
|
||||
@@ -432,6 +443,17 @@ fn pdf_settings_summarise_changes_and_warnings() {
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap();
|
||||
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
// A knockout group is written as a PDF knockout group: nothing to report.
|
||||
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
|
||||
// One whose soft masks would draw a brushed stroke is approximated.
|
||||
let rect =
|
||||
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
|
||||
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([]));
|
||||
let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap();
|
||||
assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]);
|
||||
|
||||
@@ -138,7 +138,7 @@ fn knockout_settings_round_trip_and_old_bools_load() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_knockout_groups_as_masks() {
|
||||
fn pdf_writes_knockout_groups_and_svg_writes_them_as_masks() {
|
||||
let mut s = session();
|
||||
let g = knockout_group(&mut s);
|
||||
let plain = s.doc().unwrap().doc.clone();
|
||||
@@ -149,10 +149,11 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
|
||||
let r = pdf(&plain);
|
||||
assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty());
|
||||
let r = pdf(&ko);
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
// The lower rectangle is drawn through a luminosity mask made of the upper one's alpha.
|
||||
assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
|
||||
let text = String::from_utf8_lossy(&r.bytes).replace(' ', "");
|
||||
// The rectangles knock out the area they paint: a PDF knockout group, without soft masks.
|
||||
assert_eq!(text.matches("/Ktrue").count(), 1, "a knockout group");
|
||||
assert!(!text.contains("/Luminosity"), "no soft masks");
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
|
||||
let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default());
|
||||
assert!(!svg(&plain).contains("knockout"));
|
||||
@@ -173,5 +174,5 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
|
||||
assert!(svg(&page).contains("isolation"));
|
||||
page.page_knockout = true;
|
||||
assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out");
|
||||
assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity"));
|
||||
assert!(String::from_utf8_lossy(&pdf(&page).bytes).replace(' ', "").contains("/Ktrue"));
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ use exporter::{MAX_COORD, writable, xf};
|
||||
#[cfg(test)]
|
||||
use images::{png_8_bit, png_16_bit};
|
||||
#[cfg(test)]
|
||||
pub(crate) use nodes::MEASURES;
|
||||
pub(crate) use nodes::{KNOCKOUT_BRUSHES, KNOCKOUT_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, MEASURES};
|
||||
pub(crate) use writer::{Sheet, Writer};
|
||||
|
||||
pub(crate) struct Exporter<'a> {
|
||||
@@ -127,6 +127,14 @@ pub(crate) struct Exporter<'a> {
|
||||
/// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less
|
||||
/// while one of the copies on a sheet is drawn ([`Writer::page`]).
|
||||
pub(crate) symbol_limit: u64,
|
||||
/// Objects the symbol instances in the copies drawn in knockout masks held, together: counted
|
||||
/// apart from [`Self::symbol_nodes`], within [`vectorcraft_doc::MAX_SYMBOL_NODES`] of their own.
|
||||
mask_symbol_nodes: u64,
|
||||
/// Whether a knockout mask is being drawn: [`Self::symbol_nodes`] then counts
|
||||
/// [`Self::mask_symbol_nodes`].
|
||||
in_knockout_mask: bool,
|
||||
/// Whether a knockout mask left out symbol art because the objects its copies may hold ran out.
|
||||
mask_art_left_out: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -54,6 +54,9 @@ impl<'a> Exporter<'a> {
|
||||
paper: Rect::ZERO,
|
||||
symbol_nodes: 0,
|
||||
symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES,
|
||||
mask_symbol_nodes: 0,
|
||||
in_knockout_mask: false,
|
||||
mask_art_left_out: false,
|
||||
overprint: false,
|
||||
overprinted: false,
|
||||
invisible: false,
|
||||
|
||||
+545
-62
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::exporter::{UNDECODED, address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf};
|
||||
use super::{
|
||||
Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32,
|
||||
Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
|
||||
Affine, AppearanceItem, BezPath, BlendMode, Document, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind,
|
||||
NormalizedF32, Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
|
||||
};
|
||||
|
||||
/// An object's visual box for leaving out what is off the page. A plain group or layer also holds
|
||||
@@ -57,88 +59,146 @@ impl Exporter<'_> {
|
||||
|
||||
/// The children of a group, as the elements of a knockout group when it is one.
|
||||
pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect) {
|
||||
self.measured_children(s, children, page, &[]);
|
||||
self.measured_children(s, children, page, &[], false);
|
||||
}
|
||||
|
||||
/// [`Self::children`] with the boxes of the last members in `measured`.
|
||||
fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
|
||||
/// [`Self::children`] with the boxes of the last members in `measured`, in an isolated group
|
||||
/// when `isolated`.
|
||||
fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured], isolated: bool) {
|
||||
if !self.knockout {
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
let unmeasured = children.len().saturating_sub(measured.len());
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
self.measured_node(s, c, page, false, i.checked_sub(unmeasured).and_then(|i| measured.get(i)));
|
||||
}
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let elements: Vec<_> = Node::knockout_elements(children)
|
||||
let elements: Vec<(&Node, Measured)> = Node::knockout_elements(children)
|
||||
.into_iter()
|
||||
.map(|c| (c, self.measure(c)))
|
||||
.map(|c| (&**c, self.measure(c)))
|
||||
.filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
|
||||
.collect();
|
||||
if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
|
||||
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
|
||||
return self.warp_pieces(s, &elements, page);
|
||||
if elements.is_empty() {
|
||||
return;
|
||||
}
|
||||
if elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect();
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
for (c, m) in &elements {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
|
||||
self.warp_pieces(s, &elements, page);
|
||||
} else {
|
||||
for (c, m) in &elements {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
}
|
||||
}
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
|
||||
// where the elements above it don't paint (the same look for Normal blending). The masks
|
||||
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
|
||||
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
|
||||
let Some(((first, measured), rest)) = elements.split_first() else { return };
|
||||
for (c, m) in rest.iter().rev() {
|
||||
let mask = self.knockout_mask(s, c, page, m);
|
||||
s.push_mask(mask);
|
||||
let several = elements.len() > 1;
|
||||
// A PDF knockout group (`/K`) knocks out by the area each element paints. Where an element
|
||||
// above another knocks out by less (its opacity or mask defines its knockout shape, or it
|
||||
// paints translucently), each element is drawn through one soft mask of where the
|
||||
// elements above it that overlap it paint, while those masks hold no more than
|
||||
// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects and no brushed stroke.
|
||||
if several && !elements.iter().skip(1).all(|(c, _)| shape_is_coverage(c)) {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
match knockout_masks(BrushSearch::new(doc, brushes), &elements) {
|
||||
Ok(above) => {
|
||||
// Every mask is made before anything is drawn: when the symbol objects the
|
||||
// masks may hold run out, one would leave out art its element covers, and
|
||||
// the group is written as a PDF knockout group instead.
|
||||
let outer = std::mem::take(&mut self.mask_art_left_out);
|
||||
let before = *self.mask_counter();
|
||||
let mut masks = Vec::with_capacity(above.len());
|
||||
for above in above {
|
||||
let shapes: Vec<_> = above.into_iter().map(|j| &elements[j]).collect();
|
||||
masks.push((!shapes.is_empty()).then(|| self.knockout_mask(s, &shapes, page)));
|
||||
}
|
||||
let left_out = std::mem::replace(&mut self.mask_art_left_out, outer);
|
||||
if left_out {
|
||||
*self.mask_counter() = before;
|
||||
self.warn(KNOCKOUT_SHAPES);
|
||||
} else {
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
for ((c, m), mask) in elements.iter().zip(masks) {
|
||||
if let Some(mask) = mask {
|
||||
s.push_mask(mask);
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
s.pop();
|
||||
} else {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
}
|
||||
}
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(warning) => self.warn(warning),
|
||||
}
|
||||
}
|
||||
self.measured_node(s, first, page, false, Some(measured));
|
||||
for (c, m) in rest {
|
||||
s.pop();
|
||||
let group = several || isolated;
|
||||
if group {
|
||||
s.push_group(krilla::blend::GroupOptions { isolated, knockout: several });
|
||||
}
|
||||
for (c, m) in &elements {
|
||||
// In a PDF knockout group each painting operation knocks out the ones before it. An
|
||||
// element drawn with several is one non-isolated group (drawn through a full alpha
|
||||
// mask), so that its own fills and strokes don't knock each other out; its opacity
|
||||
// or opacity mask makes it one already.
|
||||
let unit = several && !one_paint(c) && c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled);
|
||||
if unit {
|
||||
let all = constant_mask(s, page, 1.0);
|
||||
s.push_mask(all);
|
||||
}
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
if unit {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
if group {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a
|
||||
/// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels
|
||||
/// are resampled for ([`Self::warp_image_size`]).
|
||||
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
|
||||
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured], isolated: bool) {
|
||||
let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w));
|
||||
self.measured_children(s, children, page, measured);
|
||||
self.measured_children(s, children, page, measured, isolated);
|
||||
if let Some(outer) = outer {
|
||||
self.warp_size = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white,
|
||||
/// then black through an alpha mask of `c` (at full object opacity without its own mask, unless
|
||||
/// those define its shape).
|
||||
fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect, measured: &Measured) -> krilla::mask::Mask {
|
||||
let shape = if c.knockout_shape {
|
||||
c.clone()
|
||||
} else {
|
||||
let mut s = c.clone().with_mask(None);
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
self.shapes_mask(s, &[(&shape, Some(measured))], page)
|
||||
}
|
||||
|
||||
/// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the
|
||||
/// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece
|
||||
/// is drawn through one soft mask of where the pieces above it that reach its box don't cover
|
||||
/// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a
|
||||
/// few triangles and no mask nests in another. A piece above that overlaps it only over opaque
|
||||
/// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask.
|
||||
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc<Node>, Measured)], page: Rect) {
|
||||
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&Node, Measured)], page: Rect) {
|
||||
// Each piece's triangle and its box: a piece paints inside its triangle only.
|
||||
let silhouette = |c: &Node| {
|
||||
let b = c.knockout_outline()?.geometric_bounds()?;
|
||||
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b))
|
||||
};
|
||||
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|(c, _)| silhouette(c)).collect();
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect();
|
||||
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
|
||||
for (i, (c, measured)) in elements.iter().enumerate() {
|
||||
let Some((_, reach)) = &silhouettes[i] else { continue };
|
||||
@@ -191,6 +251,67 @@ impl Exporter<'_> {
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the knockout shapes of `shapes` (each with its box): white,
|
||||
/// then black through an alpha mask of each (at full object opacity without its own mask,
|
||||
/// unless those define its shape). The symbol instances in the copies count toward
|
||||
/// [`Exporter::mask_symbol_nodes`], not toward the objects the visible art may draw. A
|
||||
/// knockout mask drawn inside another one counts toward the same objects as the outer one.
|
||||
fn knockout_mask(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask {
|
||||
if self.in_knockout_mask {
|
||||
return self.knockout_mask_art(s, shapes, page);
|
||||
}
|
||||
self.in_knockout_mask = true;
|
||||
let visible = std::mem::replace(&mut self.symbol_nodes, self.mask_symbol_nodes);
|
||||
let limit = std::mem::replace(&mut self.symbol_limit, vectorcraft_doc::MAX_SYMBOL_NODES);
|
||||
let mask = self.knockout_mask_art(s, shapes, page);
|
||||
self.mask_symbol_nodes = std::mem::replace(&mut self.symbol_nodes, visible);
|
||||
self.symbol_limit = limit;
|
||||
self.in_knockout_mask = false;
|
||||
mask
|
||||
}
|
||||
|
||||
/// The symbol objects the copies drawn in knockout masks held, together: [`Self::symbol_nodes`]
|
||||
/// while a knockout mask is being drawn, [`Exporter::mask_symbol_nodes`] otherwise.
|
||||
fn mask_counter(&mut self) -> &mut u64 {
|
||||
if self.in_knockout_mask { &mut self.symbol_nodes } else { &mut self.mask_symbol_nodes }
|
||||
}
|
||||
|
||||
/// [`Self::knockout_mask`], drawn within the symbol objects counted at the time.
|
||||
fn knockout_mask_art(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask {
|
||||
let transform = s.group_transform();
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ms = Surface::stream(sb.surface());
|
||||
ms.push_transform(&transform);
|
||||
cover(&mut ms, page, 255, 1.0);
|
||||
for (c, measured) in shapes.iter().map(|e| (e.0, &e.1)) {
|
||||
let shape = if c.knockout_shape {
|
||||
c.clone()
|
||||
} else {
|
||||
let mut s = c.clone().with_mask(None);
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
let alpha = {
|
||||
let transform = ms.group_transform();
|
||||
let mut ab = ms.stream_builder();
|
||||
let mut als = Surface::stream(ab.surface());
|
||||
als.push_transform(&transform);
|
||||
let overprint = std::mem::take(&mut self.overprint);
|
||||
self.measured_node(&mut als, &shape, page, false, Some(measured));
|
||||
self.overprint = overprint;
|
||||
als.pop();
|
||||
als.finish();
|
||||
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
|
||||
};
|
||||
ms.push_mask(alpha);
|
||||
cover(&mut ms, page, 0, 1.0);
|
||||
ms.pop();
|
||||
}
|
||||
ms.pop();
|
||||
ms.finish();
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
|
||||
}
|
||||
|
||||
/// The box of `n`, and of the members of a plain group or layer.
|
||||
fn measure(&self, n: &Node) -> Measured {
|
||||
if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
|
||||
@@ -242,23 +363,7 @@ impl Exporter<'_> {
|
||||
// Whether this container's children knock each other out (a knockout group is written as a group).
|
||||
let knockout = n.knocks_out(self.knockout);
|
||||
let enclosing = std::mem::replace(&mut self.knockout, knockout);
|
||||
let mut pushes = 0;
|
||||
if n.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(n.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
|
||||
if !n.isolate && n.blends_through() {
|
||||
// Not isolated: blending inside reaches the art below the group.
|
||||
let alpha = constant_mask(s, page, n.opacity);
|
||||
s.push_mask(alpha);
|
||||
} else if n.opacity < 1.0 {
|
||||
s.push_opacity(norm(n.opacity));
|
||||
} else {
|
||||
s.push_isolated();
|
||||
}
|
||||
pushes += 1;
|
||||
}
|
||||
let (mut pushes, isolated_children) = transparency(s, n, page, container, knockout);
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
|
||||
let alpha = match (&n.kind, mask) {
|
||||
(NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated),
|
||||
@@ -279,7 +384,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
|
||||
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()), isolated_children);
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// The region every output clips to; nothing to clip by hides the clipped art. The
|
||||
@@ -366,8 +471,10 @@ impl Exporter<'_> {
|
||||
(art, count)
|
||||
})
|
||||
});
|
||||
if let Some(art) = placed.flatten() {
|
||||
self.node(s, &art, page, true);
|
||||
match placed.flatten() {
|
||||
Some(art) => self.node(s, &art, page, true),
|
||||
// A knockout mask that leaves the art out no longer matches what it masks.
|
||||
None => self.mask_art_left_out |= self.in_knockout_mask,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -741,3 +848,379 @@ impl Exporter<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a
|
||||
/// group or layer whose children `knockout` each other). Returns how many were opened, and
|
||||
/// whether the children are to be drawn in an isolated group of their own: the knockout group of
|
||||
/// an unclipped group's elements is its isolated group too.
|
||||
fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) {
|
||||
let mut pushes = 0;
|
||||
if n.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(n.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
let mut isolated_children = false;
|
||||
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
|
||||
if !n.isolate && n.blends_through() {
|
||||
// Not isolated: blending inside reaches the art below the group.
|
||||
let alpha = constant_mask(s, page, n.opacity);
|
||||
s.push_mask(alpha);
|
||||
pushes += 1;
|
||||
} else if n.opacity < 1.0 {
|
||||
s.push_opacity(norm(n.opacity));
|
||||
pushes += 1;
|
||||
} else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
|
||||
isolated_children = true;
|
||||
} else {
|
||||
s.push_isolated();
|
||||
pushes += 1;
|
||||
}
|
||||
}
|
||||
(pushes, isolated_children)
|
||||
}
|
||||
|
||||
/// The warning for a knockout group whose elements are too many to draw through soft masks: it is
|
||||
/// written as a PDF knockout group, where an element knocks out all of the area it paints.
|
||||
pub(crate) const KNOCKOUT_SHAPES: &str = "knockout groups with too many overlapping translucent elements are written as PDF knockout groups: \
|
||||
their translucent elements knock out what they cover fully";
|
||||
|
||||
/// The most objects the soft masks of one knockout group's elements draw between them: each mask
|
||||
/// draws the elements above its own element that overlap it.
|
||||
pub(crate) const MAX_KNOCKOUT_MASK_OBJECTS: usize = 1 << 16;
|
||||
|
||||
/// The most elements of one knockout group drawn through soft masks: finding the elements that
|
||||
/// overlap compares every pair.
|
||||
pub(crate) const MAX_MASKED_ELEMENTS: usize = 2048;
|
||||
|
||||
/// The warning for a knockout group in which a soft mask would draw an element with a brushed
|
||||
/// stroke: it is written as a PDF knockout group, where an element knocks out all of the area it
|
||||
/// paints.
|
||||
pub(crate) const KNOCKOUT_BRUSHES: &str = "knockout groups with brushed strokes over other translucent elements are written as PDF knockout groups: \
|
||||
their translucent elements knock out what they cover fully";
|
||||
|
||||
/// For each element of a knockout group (with its box), the indices of the elements above it whose
|
||||
/// boxes overlap its own. The warning to give instead ([`KNOCKOUT_SHAPES`]) when there are more than
|
||||
/// [`MAX_MASKED_ELEMENTS`] elements or the masks would draw more than
|
||||
/// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects, or ([`KNOCKOUT_BRUSHES`]) when a mask would draw a
|
||||
/// brushed stroke ([`BrushSearch`]), whose brush art the object count leaves out.
|
||||
fn knockout_masks(mut search: BrushSearch, elements: &[(&Node, Measured)]) -> Result<Vec<Vec<usize>>, &'static str> {
|
||||
if elements.len() > MAX_MASKED_ELEMENTS {
|
||||
return Err(KNOCKOUT_SHAPES);
|
||||
}
|
||||
let counts: Vec<usize> = elements.iter().map(|(c, _)| c.count_with_masks()).collect();
|
||||
let mut checked: Vec<Option<bool>> = vec![None; elements.len()];
|
||||
let mut objects = 0usize;
|
||||
let mut out = Vec::with_capacity(elements.len());
|
||||
for (i, (_, below)) in elements.iter().enumerate() {
|
||||
let mut above = vec![];
|
||||
for (j, (over_node, over)) in elements.iter().enumerate().skip(i + 1) {
|
||||
if let (Some(a), Some(b)) = (below.bounds, over.bounds)
|
||||
&& rects_overlap(a.inflate(1.0, 1.0), b.inflate(1.0, 1.0))
|
||||
{
|
||||
// The mask draws a copy of the element without its own opacity mask, unless the
|
||||
// mask defines its knockout shape.
|
||||
if *checked[j].get_or_insert_with(|| search.node(over_node, over_node.knockout_shape, 0)) {
|
||||
return Err(if search.exhausted { KNOCKOUT_SHAPES } else { KNOCKOUT_BRUSHES });
|
||||
}
|
||||
objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS).ok_or(KNOCKOUT_SHAPES)?;
|
||||
above.push(j);
|
||||
}
|
||||
}
|
||||
out.push(above);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The art a [`BrushSearch`] looks through once: a symbol's or a pattern's, by name, or a
|
||||
/// symbol's with one instance's appearance changes, by the symbol's name and the changes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum SharedArt {
|
||||
Symbol,
|
||||
Pattern,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// A search for the brushed strokes drawing an object draws, whose brush art can hold many more
|
||||
/// objects than the stroked path.
|
||||
struct BrushSearch<'a> {
|
||||
doc: &'a Document,
|
||||
/// The brushes the export draws strokes with ([`Exporter::brush_art`]); a stroke whose brush
|
||||
/// is not among them is drawn as a plain stroke.
|
||||
brushes: &'a [vectorcraft_brush::Brush],
|
||||
/// The answer for each symbol's and pattern's art, and each symbol's art with an instance's
|
||||
/// appearance changes, once found: false while it is being looked through, so that art that
|
||||
/// uses itself ends the search.
|
||||
seen: HashMap<(SharedArt, String), bool>,
|
||||
/// The objects the search may still visit or copy: [`MAX_KNOCKOUT_MASK_OBJECTS`] at the start.
|
||||
left: usize,
|
||||
/// Whether the search stopped because [`Self::left`] ran out; it then answers true.
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
impl<'a> BrushSearch<'a> {
|
||||
fn new(doc: &'a Document, brushes: &'a [vectorcraft_brush::Brush]) -> Self {
|
||||
Self { doc, brushes, seen: HashMap::new(), left: MAX_KNOCKOUT_MASK_OBJECTS, exhausted: false }
|
||||
}
|
||||
|
||||
/// Takes `n` objects from [`Self::left`]; false, and the search exhausted, when fewer are left.
|
||||
fn spend(&mut self, n: usize) -> bool {
|
||||
match self.left.checked_sub(n) {
|
||||
Some(left) if !self.exhausted => {
|
||||
self.left = left;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
(self.left, self.exhausted) = (0, true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the objects of `n` (its members and opacity-mask art included) from [`Self::left`],
|
||||
/// counting no further than what is left; false when they don't fit.
|
||||
fn spend_tree(&mut self, n: &Node) -> bool {
|
||||
self.spend(1)
|
||||
&& n.extras().mask.as_deref().is_none_or(|m| self.spend_tree(&m.art))
|
||||
&& n.children().into_iter().flatten().all(|c| self.spend_tree(c))
|
||||
}
|
||||
|
||||
/// Whether drawing `n` draws a brushed stroke: on `n` or its visible members (a compound
|
||||
/// path's members are drawn with its own paint), in the art of the opacity masks that are on
|
||||
/// (`n`'s own when `own_mask`), of symbol instances and inline graphics, of live objects as
|
||||
/// evaluated, and of the patterns that fills, strokes and type paint with. `depth` counts the
|
||||
/// objects and the mask, symbol and pattern art above `n`: past
|
||||
/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] the search stops and counts as a brush, and so
|
||||
/// it does when the objects it may visit or copy ([`Self::left`]) run out.
|
||||
fn node(&mut self, n: &Node, own_mask: bool, depth: usize) -> bool {
|
||||
if depth > vectorcraft_doc::depth::MAX_DRAWN_DEPTH || !self.spend(1) {
|
||||
return true;
|
||||
}
|
||||
let depth = depth + 1;
|
||||
// A fill is drawn when it is visible, and a stroke when it also paints and has a width
|
||||
// (as `Exporter::shape` draws them).
|
||||
for item in n.appearance.items.iter().filter(|i| i.visible()) {
|
||||
let paint = match item {
|
||||
AppearanceItem::Fill(f) => &f.paint,
|
||||
AppearanceItem::Stroke(st) if st.paint.is_none() || st.width <= 0.0 => continue,
|
||||
AppearanceItem::Stroke(st) => {
|
||||
if st.brush.as_deref().is_some_and(|name| self.brushes.iter().any(|b| b.name == name)) {
|
||||
return true;
|
||||
}
|
||||
&st.paint
|
||||
}
|
||||
};
|
||||
if self.paint(paint, depth) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if own_mask && n.extras().mask.as_deref().is_some_and(|m| !m.disabled && self.node(&m.art, true, depth)) {
|
||||
return true;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
// A dynamic symbol's instance draws its art with its own appearance changes: the
|
||||
// art is copied once for each set of changes, counted before it is copied.
|
||||
let doc = self.doc;
|
||||
match doc.symbols.iter().find(|s| &s.name == symbol) {
|
||||
Some(s) if !n.extras().symbol_overrides.is_empty() => {
|
||||
let key = format!("{symbol}\u{0}{:?}", n.extras().symbol_overrides);
|
||||
self.shared(SharedArt::Dynamic, &key, |search| {
|
||||
!search.spend_tree(&s.art) || search.node(&vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n), true, depth)
|
||||
})
|
||||
}
|
||||
_ => self.symbol(symbol, depth),
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| {
|
||||
let stroke = r.style.stroke_width > 0.0 && self.paint(&r.style.stroke, depth);
|
||||
stroke || self.paint(&r.style.fill, depth) || r.inline.as_ref().is_some_and(|ig| self.symbol(&ig.symbol, depth))
|
||||
}),
|
||||
NodeKind::Compound { .. } => false,
|
||||
// Live objects are drawn as their evaluated art, as `Exporter::measured_node` draws them.
|
||||
NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. } => {
|
||||
// Each live object is reached once in the art searched (shared art is searched
|
||||
// once); its evaluated art is counted before it is searched.
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
!self.spend_tree(&g) || g.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth))
|
||||
}
|
||||
_ => n.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the art of the symbol named `name` draws a brushed stroke.
|
||||
fn symbol(&mut self, name: &str, depth: usize) -> bool {
|
||||
let doc = self.doc;
|
||||
let Some(s) = doc.symbols.iter().find(|s| s.name == name) else { return false };
|
||||
self.shared(SharedArt::Symbol, name, |search| search.node(&s.art, true, depth))
|
||||
}
|
||||
|
||||
/// Whether `paint` is a pattern whose tile art draws a brushed stroke.
|
||||
fn paint(&mut self, paint: &Paint, depth: usize) -> bool {
|
||||
let Paint::Pattern { pattern, .. } = paint else { return false };
|
||||
let doc = self.doc;
|
||||
let Some(def) = doc.pattern(pattern) else { return false };
|
||||
self.shared(SharedArt::Pattern, pattern, |search| def.art.iter().filter(|c| c.visible).any(|c| search.node(c, true, depth)))
|
||||
}
|
||||
|
||||
/// The answer of `search` for the shared art `kind` named `name`, found once.
|
||||
fn shared(&mut self, kind: SharedArt, name: &str, search: impl FnOnce(&mut Self) -> bool) -> bool {
|
||||
let key = (kind, name.to_string());
|
||||
if let Some(&b) = self.seen.get(&key) {
|
||||
return b;
|
||||
}
|
||||
self.seen.insert(key.clone(), false);
|
||||
let b = search(self);
|
||||
self.seen.insert(key, b);
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` is drawn with one painting operation at most: a path or compound path with at most
|
||||
/// one visible fill or stroke that paints, in a solid color, without effects, a brush or
|
||||
/// arrowheads.
|
||||
fn one_paint(n: &Node) -> bool {
|
||||
let mut items = n.appearance.items.iter().filter(|i| i.visible() && !matches!(i.paint(), Paint::None));
|
||||
let simple = items.next().is_none_or(|i| {
|
||||
let plain = match i {
|
||||
AppearanceItem::Fill(_) => true,
|
||||
AppearanceItem::Stroke(st) => st.brush.is_none() && st.start_arrow.is_none() && st.end_arrow.is_none(),
|
||||
};
|
||||
plain && i.effects().is_empty() && matches!(i.paint(), Paint::Solid { .. })
|
||||
});
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.appearance.effects.is_empty() && simple && items.next().is_none()
|
||||
}
|
||||
|
||||
/// Whether element `c` of a knockout group knocks out all of the area it paints, as an element of
|
||||
/// a PDF knockout group does: its opacity and opacity mask are full or don't define its knockout
|
||||
/// shape, and every part of it paints opaquely.
|
||||
fn shape_is_coverage(c: &Node) -> bool {
|
||||
let own = !c.knockout_shape || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled));
|
||||
own && paints_opaque(c)
|
||||
}
|
||||
|
||||
/// Whether what `n` paints is opaque wherever it paints, at its own opacity and mask: its visible
|
||||
/// fills and strokes are solid colors or gradients with opaque stops, without effects or brushes,
|
||||
/// and so are the members of a group or compound path, at full opacity without an opacity mask.
|
||||
fn paints_opaque(n: &Node) -> bool {
|
||||
let paint = |p: &Paint| match p {
|
||||
Paint::None | Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0),
|
||||
_ => false,
|
||||
};
|
||||
let items = n.appearance.items.iter().all(|i| {
|
||||
let brush = matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some());
|
||||
!i.visible() || (i.opacity() >= 1.0 && i.effects().is_empty() && !brush && paint(i.paint()))
|
||||
});
|
||||
let member =
|
||||
|c: &std::sync::Arc<Node>| !c.visible || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled) && paints_opaque(c));
|
||||
n.appearance.effects.is_empty()
|
||||
&& items
|
||||
&& match &n.kind {
|
||||
NodeKind::Path { .. } => true,
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(member),
|
||||
NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, NodeId, Symbol, SymbolOverride};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// An instance of a dynamic symbol whose art holds an instance of the same symbol with the same
|
||||
/// appearance changes ends the search for brushed strokes where the art comes round again,
|
||||
/// after a few objects, instead of recursing until the stack runs out.
|
||||
#[test]
|
||||
fn a_dynamic_symbol_holding_itself_ends_the_brush_search() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let instance = || {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Loop".into(), xf: Affine::translate((40.0, 0.0)) });
|
||||
n.extras_mut().symbol_overrides = vec![SymbolOverride { path: vec![0], opacity: Some(0.5), ..Default::default() }];
|
||||
n
|
||||
};
|
||||
let square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(square), Arc::new(instance())]);
|
||||
d.symbols.push(Symbol { dynamic: true, name: "Loop".into(), art: Arc::new(art) });
|
||||
let mut search = BrushSearch::new(&d, &[]);
|
||||
assert!(!search.node(&instance(), true, 0));
|
||||
assert!(!search.exhausted);
|
||||
assert!(MAX_KNOCKOUT_MASK_OBJECTS - search.left < 10, "{} objects", MAX_KNOCKOUT_MASK_OBJECTS - search.left);
|
||||
}
|
||||
|
||||
/// A group of 1,000 instances of a dynamic symbol whose art holds 100 squares, each instance
|
||||
/// with appearance changes of its own: copying and looking through every instance's art would
|
||||
/// take about 203,000 objects, and the search stops when [`MAX_KNOCKOUT_MASK_OBJECTS`] are
|
||||
/// used, counting as a brush. With the same changes on every instance, the art is copied
|
||||
/// once.
|
||||
#[test]
|
||||
fn the_brush_search_stops_within_its_object_limit() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let square =
|
||||
Arc::new(Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::None, Paint::None, 0.0)));
|
||||
d.symbols.push(Symbol { dynamic: true, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), vec![square; 100])) });
|
||||
let group = |distinct: bool| {
|
||||
let instances = (0..1000u32)
|
||||
.map(|i| {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Dots".into(), xf: Affine::IDENTITY });
|
||||
let path = if distinct { vec![0, i] } else { vec![0] };
|
||||
n.extras_mut().symbol_overrides = vec![SymbolOverride { path, opacity: Some(0.5), ..Default::default() }];
|
||||
Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
Node::group(NodeId(0), instances)
|
||||
};
|
||||
let mut search = BrushSearch::new(&d, &[]);
|
||||
assert!(search.node(&group(true), true, 0));
|
||||
assert!(search.exhausted);
|
||||
assert_eq!(search.left, 0);
|
||||
let mut search = BrushSearch::new(&d, &[]);
|
||||
assert!(!search.node(&group(false), true, 0));
|
||||
assert!(!search.exhausted);
|
||||
// The group, the 1,000 instances, and one copy of the art counted and looked through.
|
||||
assert_eq!(MAX_KNOCKOUT_MASK_OBJECTS - search.left, 1 + 1000 + 101 + 101);
|
||||
}
|
||||
|
||||
/// A placed file read as one square with a 3 pt brushed stroke.
|
||||
fn brushed_art() -> (Document, Node, Rect) {
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.layers.clear();
|
||||
let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), 3.0);
|
||||
st.brush = Some("3 pt. Round".into());
|
||||
let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
square.appearance.items.push(AppearanceItem::Stroke(st));
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(square)]);
|
||||
(d, art, Rect::new(0.0, 0.0, 40.0, 20.0))
|
||||
}
|
||||
|
||||
/// A placed document is drawn as the art read from its file, and the brushed strokes in that
|
||||
/// art count.
|
||||
#[test]
|
||||
fn brushed_strokes_in_a_placed_documents_art_count() {
|
||||
let d = Document::new(200.0, 200.0);
|
||||
let placed = vectorcraft_doc::PlacedDocument {
|
||||
link: vectorcraft_doc::LinkInfo::new("/art/placed.vectorcraft"),
|
||||
key: "placed-1".into(),
|
||||
bounding: false,
|
||||
page_box: vectorcraft_doc::placed_document::PageBox::Crop,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
xf: Affine::IDENTITY,
|
||||
placement: Default::default(),
|
||||
};
|
||||
// The art the export reads for it before drawing (`Document::with_placed_art`).
|
||||
vectorcraft_doc::placed_document::prime(&placed, Arc::new(vec![]), brushed_art());
|
||||
let n = Node::new(NodeId(0), NodeKind::PlacedDocument(Box::new(placed)));
|
||||
let brushes = vectorcraft_brush::library(&d);
|
||||
assert!(BrushSearch::new(&d, &brushes).node(&n, true, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,21 @@ impl<'p> Builder<'p> {
|
||||
only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m))));
|
||||
}
|
||||
Some(only)
|
||||
} else if let [only] = children.as_slice()
|
||||
&& matches!(only.kind, NodeKind::Group { clip: false, .. })
|
||||
&& only.knockout == Knockout::On
|
||||
&& only.opacity >= 0.999
|
||||
&& only.blend == BlendMode::Normal
|
||||
&& !only.isolate
|
||||
&& only.extras().mask.is_none()
|
||||
&& mask.is_none()
|
||||
&& !knockout
|
||||
{
|
||||
// A knockout group drawn in a form that carries its opacity, blend mode or
|
||||
// isolation: one knockout group with them.
|
||||
let mut only = (**only).clone();
|
||||
(only.opacity, only.blend, only.isolate) = (opacity, blend, isolate);
|
||||
Some(only)
|
||||
} else {
|
||||
let mut g = Node::group(self.id(), children);
|
||||
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
|
||||
|
||||
@@ -81,6 +81,11 @@ impl<'a> Surface<'a> {
|
||||
self.group(|s| s.push_isolated());
|
||||
}
|
||||
|
||||
/// A transparency group with `options` (isolated, knockout), as [`Self::push_isolated`].
|
||||
pub fn push_group(&mut self, options: krilla::blend::GroupOptions) {
|
||||
self.group(|s| s.push_group(options));
|
||||
}
|
||||
|
||||
/// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art.
|
||||
pub fn push_mask(&mut self, mask: Mask) {
|
||||
self.group(|s| s.push_mask(mask));
|
||||
|
||||
@@ -97,3 +97,466 @@ fn a_knockout_group_with_blending_elements_is_non_isolated_unless_isolated() {
|
||||
assert_eq!(text(&d).contains("/Itrue"), isolate, "isolate {isolate}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an
|
||||
/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape.
|
||||
fn knockout_squares(n: usize, knockout_shape: bool, isolate: bool) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 200.0, 200.0), (0.9, 0.8, 0.2)));
|
||||
let colors = [(1.0, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 1.0)];
|
||||
let squares = (0..n)
|
||||
.map(|i| {
|
||||
let x = (i % 20) as f64 * 2.0;
|
||||
let mut s = rect(Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]);
|
||||
(s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, knockout_shape);
|
||||
std::sync::Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
let mut g = Node::group(NodeId(0), squares);
|
||||
(g.knockout, g.isolate) = (vectorcraft_doc::Knockout::On, isolate);
|
||||
add(&mut d, g);
|
||||
d
|
||||
}
|
||||
|
||||
/// How many objects each knockout group of `d` holds.
|
||||
fn knockout_groups(d: &Document) -> Vec<usize> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if n.knockout == vectorcraft_doc::Knockout::On {
|
||||
out.push(n.children().map_or(0, |c| c.len()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A knockout group whose elements knock out the area they paint is written as one PDF knockout
|
||||
/// group (`/K true`), whatever the number of elements, and reads back as a knockout group that
|
||||
/// draws as the original does.
|
||||
#[test]
|
||||
fn a_knockout_group_is_written_as_one_pdf_knockout_group() {
|
||||
for n in [0, 1, 2, 49, 50, 200] {
|
||||
let d = knockout_squares(n, false, false);
|
||||
let t = text(&d);
|
||||
assert_eq!(t.matches("/Ktrue").count(), usize::from(n > 1), "{n}");
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("{n}: {e}"));
|
||||
assert_eq!(knockout_groups(&back), if n > 1 { vec![n] } else { vec![] }, "{n}");
|
||||
if n == 200 {
|
||||
vectorcraft_testkit::raster::assert_similar(
|
||||
&vectorcraft_testkit::raster::render_artboard(&back),
|
||||
&vectorcraft_testkit::raster::render_artboard(&d),
|
||||
4.0,
|
||||
0.01,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The knockout group of an isolated group is isolated too (`/I true` beside `/K true`), and the
|
||||
/// group reads back as isolated and knocking out. (Each translucent element is a group of its own.)
|
||||
#[test]
|
||||
fn an_isolated_knockout_group_is_one_isolated_pdf_knockout_group() {
|
||||
for isolate in [false, true] {
|
||||
let mut d = knockout_squares(3, false, isolate);
|
||||
// Blending inside, which isolation keeps from the backdrop.
|
||||
let g = d.layers[0].children().unwrap()[1].id;
|
||||
let top = d.node(g).unwrap().children().unwrap()[2].id;
|
||||
d.node_mut(top).unwrap().blend = BlendMode::Multiply;
|
||||
let t = text(&d);
|
||||
assert_eq!(t.matches("/Ktrue").count(), 1, "isolate {isolate}");
|
||||
assert_eq!(t.contains("/Itrue/Ktrue"), isolate, "isolate {isolate}");
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut found = vec![];
|
||||
back.walk(|n| {
|
||||
if n.knockout == vectorcraft_doc::Knockout::On {
|
||||
found.push(n.isolate);
|
||||
}
|
||||
});
|
||||
assert_eq!(found, [isolate]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A translucent knockout group is written as a form with its opacity around the `/K` group, and
|
||||
/// reads back as one knockout group with that opacity and its isolation.
|
||||
#[test]
|
||||
fn a_translucent_knockout_group_reads_back_as_one_group() {
|
||||
for isolate in [false, true] {
|
||||
let mut d = knockout_squares(3, false, isolate);
|
||||
let g = d.layers[0].children().unwrap()[1].id;
|
||||
d.node_mut(g).unwrap().opacity = 0.5;
|
||||
let top = d.node(g).unwrap().children().unwrap()[2].id;
|
||||
d.node_mut(top).unwrap().blend = BlendMode::Multiply;
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut found = vec![];
|
||||
back.walk(|n| {
|
||||
if matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. }) {
|
||||
found.push((
|
||||
n.knockout == vectorcraft_doc::Knockout::On,
|
||||
n.isolate,
|
||||
(n.opacity * 100.0).round(),
|
||||
n.children().map_or(0, |c| c.len()),
|
||||
));
|
||||
}
|
||||
});
|
||||
assert_eq!(found, [(true, isolate, 50.0, 3)], "isolate {isolate}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Elements whose opacity defines their knockout shape knock out less than a PDF knockout group
|
||||
/// knocks out: each is drawn through one soft mask of the elements above it, so that 200 of them
|
||||
/// still read back, every one of them.
|
||||
#[test]
|
||||
fn elements_whose_opacity_defines_their_knockout_shape_are_drawn_through_one_mask_each() {
|
||||
let d = knockout_squares(200, true, false);
|
||||
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
|
||||
assert!(!text(&d).contains("/Ktrue"));
|
||||
let back = import(&r.bytes).unwrap();
|
||||
let mut paths = 0;
|
||||
back.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
// The backdrop and the 200 squares (the masks' copies of them are opacity-mask art).
|
||||
assert_eq!(paths, 201);
|
||||
}
|
||||
|
||||
/// When the masks would draw more than [`crate::export::MAX_KNOCKOUT_MASK_OBJECTS`] objects, the
|
||||
/// group is written as a PDF knockout group, with a warning, and reads back as a knockout group.
|
||||
#[test]
|
||||
fn a_knockout_group_too_large_to_mask_is_a_pdf_knockout_group_with_a_warning() {
|
||||
// 400 elements that all overlap: 79,800 objects in their masks.
|
||||
const { assert!(400 * 399 / 2 > crate::export::MAX_KNOCKOUT_MASK_OBJECTS) };
|
||||
let d = knockout_squares(400, true, false);
|
||||
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{:?}", r.warnings);
|
||||
assert_eq!(text(&d).matches("/Ktrue").count(), 1);
|
||||
assert_eq!(knockout_groups(&import(&r.bytes).unwrap()), [400]);
|
||||
}
|
||||
|
||||
/// In a PDF knockout group each painting operation knocks out the ones before it: an element with
|
||||
/// two fills, the second in Multiply, is drawn as one group (through a full alpha mask), so that
|
||||
/// its second fill blends with its first. An element with one fill needs no group.
|
||||
#[test]
|
||||
fn an_element_with_several_paints_is_one_group_in_a_knockout_group() {
|
||||
for (fills, groups) in [(1, 0), (2, 1)] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let below = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.0, 0.0, 1.0));
|
||||
let mut top = rect(Rect::new(50.0, 50.0, 150.0, 150.0), (1.0, 0.0, 0.0));
|
||||
if fills == 2 {
|
||||
let mut multiply = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
multiply.blend = BlendMode::Multiply;
|
||||
top.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(multiply));
|
||||
}
|
||||
top.id = d.alloc_id();
|
||||
let below = Node { id: d.alloc_id(), ..below };
|
||||
let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(below), std::sync::Arc::new(top)]);
|
||||
g.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, g);
|
||||
let t = text(&d);
|
||||
assert_eq!(t.matches("/Ktrue").count(), 1, "{fills}");
|
||||
let Some(start) = t.find("/Ktrue") else { panic!("no knockout group") };
|
||||
let group = &t[start..];
|
||||
let Some(end) = group.find("endstream") else { panic!("no knockout stream") };
|
||||
assert_eq!(group[..end].matches("Do").count(), groups, "{fills}: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Elements that don't overlap need no masks of each other.
|
||||
#[test]
|
||||
fn knockout_masks_hold_only_the_elements_that_overlap() {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let squares = (0..3)
|
||||
.map(|i| {
|
||||
let x = f64::from(i) * 60.0;
|
||||
let mut s = rect(Rect::new(x, 0.0, x + 40.0, 40.0), (0.0, 0.0, 1.0));
|
||||
(s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
std::sync::Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
let mut g = Node::group(NodeId(0), squares);
|
||||
g.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, g);
|
||||
let t = text(&d);
|
||||
assert!(!t.contains("/Luminosity") && !t.contains("/Ktrue"), "{t}");
|
||||
}
|
||||
|
||||
/// The copies of symbol instances drawn in knockout masks don't count toward the objects the
|
||||
/// visible symbol art of an export may hold (`MAX_SYMBOL_NODES`): of two overlapping 50% instances
|
||||
/// whose art placed through 9-slice guides holds about 6,510,000 objects each (a gradient square
|
||||
/// in all nine cells, each copy with a disabled opacity mask of 723,351 objects), both are written
|
||||
/// in their nine cells, although the lower one's mask draws a copy of the upper one first.
|
||||
#[test]
|
||||
fn knockout_mask_copies_of_symbol_instances_leave_the_visible_art_its_object_limit() {
|
||||
let d = panel_instances(850, &[20.0, 40.0]);
|
||||
// Nine squares of each instance (the mask's copy of the upper one is opacity-mask art).
|
||||
assert_eq!(painted_paths(&export(&d, &PdfOptions::default()).unwrap()), 18);
|
||||
}
|
||||
|
||||
/// A knockout group of overlapping 50% instances, at `xs`, of a symbol whose art placed through
|
||||
/// 9-slice guides holds 9 × (`mask_objects`² + `mask_objects` + 3) objects (a gradient square in
|
||||
/// all nine cells, each copy with a disabled opacity mask of `mask_objects`² + `mask_objects` + 1
|
||||
/// objects).
|
||||
fn panel_instances(mask_objects: usize, xs: &[f64]) -> Document {
|
||||
use vectorcraft_color::{Gradient, GradientPaint};
|
||||
use vectorcraft_geom::Affine;
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let leaf = std::sync::Arc::new(rect(Rect::new(0.0, 0.0, 1.0, 1.0), (0.0, 0.0, 0.0)));
|
||||
let inner = std::sync::Arc::new(Node::group(NodeId(0), vec![leaf; mask_objects]));
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(0), vec![inner; mask_objects]), true);
|
||||
(mask.linked, mask.disabled) = (false, true);
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(mask));
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(square)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: std::sync::Arc::new(art) });
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let instances = xs
|
||||
.iter()
|
||||
.map(|&x| {
|
||||
let xf = Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5);
|
||||
let mut n = Node::new(NodeId(0), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Panel".into(), xf });
|
||||
(n.id, n.opacity, n.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
std::sync::Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
let mut g = Node::group(NodeId(0), instances);
|
||||
g.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, g);
|
||||
d
|
||||
}
|
||||
|
||||
/// The paths of `pdf` read back, the art of opacity masks left out.
|
||||
fn painted_paths(pdf: &[u8]) -> usize {
|
||||
let mut paths = 0;
|
||||
import(pdf).unwrap().walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
paths
|
||||
}
|
||||
|
||||
/// When the copies the knockout masks draw would hold more symbol objects than they may
|
||||
/// (`MAX_SYMBOL_NODES` of their own), the group is written as a PDF knockout group, with a
|
||||
/// warning, instead of masks that leave out art their elements cover: four overlapping instances
|
||||
/// of about 3,870,000 objects each fit the visible art's limit, and the six copies in their masks
|
||||
/// do not.
|
||||
#[test]
|
||||
fn knockout_masks_whose_symbol_copies_run_out_give_way_to_a_pdf_knockout_group() {
|
||||
let d = panel_instances(655, &[20.0, 40.0, 60.0, 80.0]);
|
||||
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{:?}", r.warnings);
|
||||
assert_eq!(painted_paths(&r.bytes), 36);
|
||||
}
|
||||
|
||||
/// A brushed stroke draws brush art that the objects counted for the knockout masks leave out:
|
||||
/// a group in which a mask would draw an element with a brushed stroke is written as a PDF
|
||||
/// knockout group, with a warning. A brushed element that no mask draws (the lowest one) leaves
|
||||
/// the masks as they are.
|
||||
#[test]
|
||||
fn a_knockout_group_whose_masks_would_draw_a_brushed_stroke_is_a_pdf_knockout_group() {
|
||||
for (brushed, expected) in [(1, true), (0, false)] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let squares = (0..2)
|
||||
.map(|i| {
|
||||
let x = f64::from(i) * 40.0;
|
||||
let mut s = rect(Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), (0.0, 0.0, 1.0));
|
||||
(s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
if i == brushed {
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||||
st.brush = Some("3 pt. Round".into());
|
||||
s.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st));
|
||||
}
|
||||
std::sync::Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
let mut g = Node::group(NodeId(0), squares);
|
||||
g.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, g);
|
||||
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
|
||||
assert_eq!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES), expected, "{brushed}: {:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{brushed}: {:?}", r.warnings);
|
||||
assert_eq!(text(&d).matches("/Ktrue").count(), usize::from(expected), "{brushed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The knockout group of `upper` at 50%, whose opacity defines its knockout shape when
|
||||
/// `knockout_shape`, between two 50% squares whose opacity defines theirs (so that the group is
|
||||
/// drawn through masks): whether the export gives the brushed-stroke warning.
|
||||
fn warns_of_brushes(mut d: Document, mut upper: Node, knockout_shape: bool) -> bool {
|
||||
(upper.id, upper.opacity, upper.knockout_shape) = (d.alloc_id(), 0.5, knockout_shape);
|
||||
let mut lower = rect(Rect::new(20.0, 50.0, 120.0, 150.0), (1.0, 0.0, 0.0));
|
||||
(lower.id, lower.opacity, lower.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
let mut top = rect(Rect::new(100.0, 50.0, 180.0, 150.0), (0.0, 0.8, 0.0));
|
||||
(top.id, top.opacity, top.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
let mut g = Node::group(NodeId(0), [lower, upper, top].map(std::sync::Arc::new).to_vec());
|
||||
g.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, g);
|
||||
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
|
||||
r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES)
|
||||
}
|
||||
|
||||
/// A square at (60, 50) with a 3 pt brushed stroke.
|
||||
fn brushed_square() -> Node {
|
||||
let mut line = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0));
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||||
st.brush = Some("3 pt. Round".into());
|
||||
line.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st));
|
||||
line
|
||||
}
|
||||
|
||||
/// Only brushed strokes a mask draws count: a hidden member's is not drawn, nor is one without
|
||||
/// paint or width, nor the one in the opacity mask of an element whose mask does not define its
|
||||
/// knockout shape (its copy in a mask leaves the opacity mask out).
|
||||
#[test]
|
||||
fn brushed_strokes_that_a_mask_does_not_draw_leave_the_masks_as_they_are() {
|
||||
let d = Document::new(200.0, 200.0);
|
||||
let mut hidden = brushed_square();
|
||||
hidden.visible = false;
|
||||
let plain = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (0.0, 0.0, 1.0));
|
||||
let group = Node::group(NodeId(0), vec![std::sync::Arc::new(plain.clone()), std::sync::Arc::new(hidden)]);
|
||||
assert!(!warns_of_brushes(d.clone(), group, true));
|
||||
// A brushed stroke without paint or width draws nothing.
|
||||
for (paint, width) in [(Paint::None, 3.0), (Paint::solid(Color::BLACK), 0.0)] {
|
||||
let mut idle = brushed_square();
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = idle.appearance.items.last_mut() {
|
||||
(st.paint, st.width) = (paint, width);
|
||||
}
|
||||
assert!(!warns_of_brushes(d.clone(), idle, true), "width {width}");
|
||||
}
|
||||
assert!(warns_of_brushes(d.clone(), brushed_square(), true));
|
||||
// A group's own brushed stroke is drawn too: the export bakes it into a path among the
|
||||
// group's members.
|
||||
let mut stroked_group = Node::group(NodeId(0), vec![std::sync::Arc::new(plain.clone())]);
|
||||
stroked_group.appearance = brushed_square().appearance;
|
||||
assert!(warns_of_brushes(d.clone(), stroked_group, true));
|
||||
for (knockout_shape, expected) in [(false, false), (true, true)] {
|
||||
let mut masked = plain.clone();
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(brushed_square(), true);
|
||||
mask.linked = false;
|
||||
masked.extras_mut().mask = Some(Box::new(mask));
|
||||
assert_eq!(warns_of_brushes(d.clone(), masked, knockout_shape), expected, "knockout shape {knockout_shape}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A compound path's members are drawn with its own paint, so a brushed stroke on a member draws
|
||||
/// nothing; one on the compound path does.
|
||||
#[test]
|
||||
fn a_brushed_stroke_counts_on_a_compound_path_and_not_on_its_members() {
|
||||
let d = Document::new(200.0, 200.0);
|
||||
let compound = |members: Vec<Node>| {
|
||||
let children = members.into_iter().map(std::sync::Arc::new).collect();
|
||||
let mut c = Node::new(NodeId(0), vectorcraft_doc::NodeKind::Compound { children, rule: Default::default() });
|
||||
c.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
c
|
||||
};
|
||||
assert!(!warns_of_brushes(d.clone(), compound(vec![brushed_square()]), true));
|
||||
let mut stroked = compound(vec![rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0))]);
|
||||
stroked.appearance = brushed_square().appearance;
|
||||
assert!(warns_of_brushes(d, stroked, true));
|
||||
}
|
||||
|
||||
/// A pattern's tiles draw the brushed strokes in its art: an element filled with such a pattern
|
||||
/// counts, and one filled with a pattern without them does not.
|
||||
#[test]
|
||||
fn a_brushed_stroke_in_a_pattern_counts() {
|
||||
for (brushed_tile, expected) in [(true, true), (false, false)] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let tile = if brushed_tile { brushed_square() } else { rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0)) };
|
||||
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(tile)]));
|
||||
let pattern = Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
let filled = Node::path(NodeId(0), shapes::rectangle(Rect::new(60.0, 50.0, 160.0, 150.0)), Appearance::basic(pattern, Paint::None, 0.0));
|
||||
assert_eq!(warns_of_brushes(d, filled, true), expected, "brushed tile {brushed_tile}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke whose brush the export does not know is drawn as a plain stroke, and a stroke without
|
||||
/// width draws nothing, not even its pattern's brushed strokes.
|
||||
#[test]
|
||||
fn strokes_that_draw_no_brush_art_leave_the_masks_as_they_are() {
|
||||
let mut unknown = brushed_square();
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = unknown.appearance.items.last_mut() {
|
||||
st.brush = Some("No Such Brush".into());
|
||||
}
|
||||
assert!(!warns_of_brushes(Document::new(200.0, 200.0), unknown, true));
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(brushed_square())]));
|
||||
let pattern = Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
let mut thin = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (0.0, 0.0, 1.0));
|
||||
thin.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(pattern, 0.0)));
|
||||
assert!(!warns_of_brushes(d, thin, true));
|
||||
}
|
||||
|
||||
/// Type draws the brushed strokes in the patterns its characters are filled with and in the art
|
||||
/// of its inline graphics.
|
||||
#[test]
|
||||
fn brushed_strokes_in_type_patterns_and_inline_graphics_count() {
|
||||
let text = |d: &mut Document, fill: Paint, inline: bool| {
|
||||
let style = vectorcraft_doc::CharStyle { size: 60.0, fill, ..Default::default() };
|
||||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(60.0, 120.0), "Type", style.clone());
|
||||
if inline {
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Brushed".into(), art: std::sync::Arc::new(brushed_square()) });
|
||||
let mut run = vectorcraft_doc::TextRun::new("\u{FFFC}", style);
|
||||
run.inline = Some(vectorcraft_doc::InlineArt::new("Brushed"));
|
||||
t.runs.push(run);
|
||||
}
|
||||
Node::new(NodeId(0), vectorcraft_doc::NodeKind::Text(Box::new(t)))
|
||||
};
|
||||
let black = Paint::solid(Color::BLACK);
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let plain = text(&mut d, black.clone(), false);
|
||||
assert!(!warns_of_brushes(d.clone(), plain, true));
|
||||
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(brushed_square())]));
|
||||
let patterned = text(&mut d, Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY }, false);
|
||||
assert!(warns_of_brushes(d, patterned, true));
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let inline = text(&mut d, black, true);
|
||||
assert!(warns_of_brushes(d, inline, true));
|
||||
}
|
||||
|
||||
/// Type draws the brushed strokes of its appearance as brush art too.
|
||||
#[test]
|
||||
fn a_brushed_stroke_on_type_counts() {
|
||||
let style = vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(60.0, 120.0), "Type", style);
|
||||
let mut n = Node::new(NodeId(0), vectorcraft_doc::NodeKind::Text(Box::new(text)));
|
||||
n.appearance = brushed_square().appearance;
|
||||
assert!(warns_of_brushes(Document::new(200.0, 200.0), n, true));
|
||||
}
|
||||
|
||||
/// The brushed strokes a mask would draw are found in the opacity-mask art of a symbol's art too,
|
||||
/// and a disabled opacity mask, which is not drawn, holds none.
|
||||
#[test]
|
||||
fn brushed_strokes_in_mask_art_count_only_where_the_mask_is_on() {
|
||||
for (in_symbol, disabled, expected) in [(true, false, true), (false, true, false), (false, false, true)] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let mut line = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (1.0, 1.0, 1.0));
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||||
st.brush = Some("3 pt. Round".into());
|
||||
line.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st));
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(line, true);
|
||||
(mask.linked, mask.disabled) = (false, disabled);
|
||||
let mut masked = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.0, 0.0, 1.0));
|
||||
masked.extras_mut().mask = Some(Box::new(mask));
|
||||
let mut upper = if in_symbol {
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(masked)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Masked".into(), art: std::sync::Arc::new(art) });
|
||||
let xf = vectorcraft_geom::Affine::translate((60.0, 50.0));
|
||||
Node::new(NodeId(0), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Masked".into(), xf })
|
||||
} else {
|
||||
masked.transform(vectorcraft_geom::Affine::translate((60.0, 50.0)), false);
|
||||
masked
|
||||
};
|
||||
(upper.id, upper.opacity, upper.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
let mut lower = rect(Rect::new(20.0, 50.0, 120.0, 150.0), (1.0, 0.0, 0.0));
|
||||
(lower.id, lower.opacity, lower.knockout_shape) = (d.alloc_id(), 0.5, true);
|
||||
let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(lower), std::sync::Arc::new(upper)]);
|
||||
g.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, g);
|
||||
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
|
||||
let warned = r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES);
|
||||
assert_eq!(warned, expected, "symbol {in_symbol}, disabled {disabled}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF/A-2 allows transparency: a knockout group is written as one there too.
|
||||
#[test]
|
||||
fn a_pdf_a_2b_file_keeps_a_knockout_group() {
|
||||
let mut o = PdfOptions::default();
|
||||
(o.settings.standard, o.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version());
|
||||
let bytes = export(&knockout_squares(3, false, false), &o).unwrap();
|
||||
assert_eq!(knockout_groups(&import(&bytes).unwrap()), [3]);
|
||||
}
|
||||
|
||||
@@ -91,9 +91,7 @@ fn fixture(form: Form, kind: GradientKind, mode: ColorMode, origin: Point) -> (D
|
||||
/// Round trips preserve gradient placement for the form types the importer supports.
|
||||
#[test]
|
||||
fn gradients_in_forms_keep_their_position_when_read_back() {
|
||||
// The importer has a pre-existing mismatch on the knockout's synthesized nested masks;
|
||||
// Quartz below checks its actual PDF output, along with the other forms.
|
||||
for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Knockout)) {
|
||||
for form in FORMS {
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] {
|
||||
let (d, opts) = fixture(form, kind, ColorMode::Rgb, origin);
|
||||
|
||||
+149
-64
@@ -163,6 +163,18 @@ fn pin_to_page(n: &mut Node, m: Affine) {
|
||||
|
||||
/// What SVG Tiny 1.2 does without masks.
|
||||
const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups";
|
||||
|
||||
/// What a knockout group too large to mask is written as.
|
||||
const KNOCKOUT_SIZE: &str = "knockout groups with too many overlapping elements to mask are written as plain groups";
|
||||
|
||||
/// The most knockout shapes the masks of one knockout group's elements use between them: each
|
||||
/// mask uses the shapes of the elements above its own element that overlap it.
|
||||
const MAX_KNOCKOUT_MASK_SHAPES: usize = 1 << 16;
|
||||
|
||||
/// The most elements of one knockout group drawn through masks: finding the elements that
|
||||
/// overlap compares every pair.
|
||||
const MAX_MASKED_ELEMENTS: usize = 2048;
|
||||
|
||||
pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
/// The region of a mask or filter over art that has no box to measure: one covering any artwork.
|
||||
@@ -450,20 +462,39 @@ impl Writer<'_> {
|
||||
region
|
||||
}
|
||||
|
||||
/// The box of what each of `nodes` paints, as [`Self::paint_box`] measures it.
|
||||
fn paint_boxes(&mut self, nodes: &[&Node]) -> Vec<Option<Option<Rect>>> {
|
||||
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 boxes = nodes.iter().map(|n| self.paint_box(&symbols, &mut art_reach, n)).collect();
|
||||
self.region_boxes = Some(symbols);
|
||||
self.art_reach = art_reach;
|
||||
boxes
|
||||
}
|
||||
|
||||
/// The box of what `n` paints in the current user space, as [`Self::region_attrs`] measures
|
||||
/// it: `Some(None)` when it has no box, `None` when the box can't be told (type, or nesting
|
||||
/// deeper than [`MAX_REACH_DEPTH`]).
|
||||
fn paint_box(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, n: &Node) -> Option<Option<Rect>> {
|
||||
let Some(v) = n.visual_bounds_with(symbols) else { return Some(None) };
|
||||
let r = self.paint_reach(symbols, art_reach, n, v, 0)?;
|
||||
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);
|
||||
Some(Some(scaled.union(unscaled)))
|
||||
}
|
||||
|
||||
/// [`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 {
|
||||
let Some(b) = self.paint_box(symbols, art_reach, n) 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)));
|
||||
if let Some(both) = b {
|
||||
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();
|
||||
@@ -572,8 +603,10 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
/// Children of the group being written. SVG has no knockout groups: in one, each element is
|
||||
/// drawn through a mask of where the elements above it don't paint (the same look for Normal
|
||||
/// blending). The masks nest, so element i sits inside the masks of elements i+1…n.
|
||||
/// drawn through one mask of where the elements above it that overlap it don't paint (the
|
||||
/// same look for Normal blending). The masks share one copy of each element's knockout shape,
|
||||
/// and the elements stay siblings however many there are. A group whose masks would hold more
|
||||
/// than [`MAX_KNOCKOUT_MASK_SHAPES`] shapes is written as a plain group, with a warning.
|
||||
pub(super) fn children(&mut self, children: &[std::sync::Arc<Node>]) {
|
||||
if self.knockout && self.tiny() {
|
||||
self.warn(TINY_MASKS);
|
||||
@@ -603,36 +636,124 @@ impl Writer<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((first, rest)) = elements.split_first() else { return };
|
||||
// The elements are written first, in paint order, so that each mask copies an element
|
||||
let all: Vec<&Node> = elements.iter().map(|c| &***c).collect();
|
||||
let Some(above) = self.knockout_masks(&all) else {
|
||||
self.warn(KNOCKOUT_SIZE);
|
||||
for c in elements {
|
||||
self.node(c);
|
||||
}
|
||||
return;
|
||||
};
|
||||
// The elements are written first, in paint order, so that each shape copies an element
|
||||
// with the symbol art count the element itself was written with
|
||||
// (`Self::symbol_nodes`): an instance left out of an element is left out of its mask.
|
||||
let (depth, nest) = (self.depth, rest.len());
|
||||
let first = self.detached(depth + nest, |w| w.node(first));
|
||||
let mut written = Vec::with_capacity(nest);
|
||||
for (i, c) in rest.iter().enumerate() {
|
||||
// (`Self::symbol_nodes`): an instance left out of an element is left out of its shape.
|
||||
let depth = self.depth;
|
||||
let mut written = Vec::with_capacity(elements.len());
|
||||
for (c, above) in elements.iter().zip(&above) {
|
||||
let before = self.symbol_nodes;
|
||||
written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c))));
|
||||
written.push((before, self.detached(depth + usize::from(!above.is_empty()), |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, ®ion);
|
||||
self.line(&format!("<g mask=\"url(#{mid})\">"));
|
||||
self.depth += 1;
|
||||
let mut shapes: Vec<Option<String>> = vec![None; elements.len()];
|
||||
for &j in above.iter().flatten() {
|
||||
if shapes[j].is_none() {
|
||||
self.symbol_nodes = written[j].0;
|
||||
shapes[j] = Some(self.knockout_shape(elements[j]));
|
||||
}
|
||||
}
|
||||
self.symbol_nodes = after;
|
||||
self.body.push_str(&first);
|
||||
for (_, body) in written {
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
for (above, (_, body)) in above.iter().zip(written) {
|
||||
if above.is_empty() {
|
||||
self.body.push_str(&body);
|
||||
continue;
|
||||
}
|
||||
let ids: Vec<&str> = above.iter().filter_map(|&j| shapes[j].as_deref()).collect();
|
||||
let mid = self.knockout_mask(&ids, ®ion);
|
||||
self.line(&format!("<g mask=\"url(#{mid})\">"));
|
||||
self.body.push_str(&body);
|
||||
self.line("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
/// For each element of a knockout group, the indices of the elements above it whose painted
|
||||
/// boxes overlap its own (every one above it when a box can't be told). `None` when there are
|
||||
/// more than [`MAX_MASKED_ELEMENTS`] elements or the masks would hold more than
|
||||
/// [`MAX_KNOCKOUT_MASK_SHAPES`] shapes.
|
||||
fn knockout_masks(&mut self, elements: &[&Node]) -> Option<Vec<Vec<usize>>> {
|
||||
if elements.len() > MAX_MASKED_ELEMENTS {
|
||||
return None;
|
||||
}
|
||||
let boxes = self.paint_boxes(elements);
|
||||
let overlap = |a: Option<Option<Rect>>, b: Option<Option<Rect>>| match (a, b) {
|
||||
(Some(None), _) | (_, Some(None)) => false,
|
||||
(Some(Some(a)), Some(Some(b))) => a.x0 <= b.x1 + 2.0 && b.x0 <= a.x1 + 2.0 && a.y0 <= b.y1 + 2.0 && b.y0 <= a.y1 + 2.0,
|
||||
_ => true,
|
||||
};
|
||||
let mut shapes = 0usize;
|
||||
let mut out = Vec::with_capacity(elements.len());
|
||||
for (i, &below) in boxes.iter().enumerate() {
|
||||
let mut above = vec![];
|
||||
for (j, &over) in boxes.iter().enumerate().skip(i + 1) {
|
||||
if overlap(below, over) {
|
||||
shapes = shapes.checked_add(1).filter(|&s| s <= MAX_KNOCKOUT_MASK_SHAPES)?;
|
||||
above.push(j);
|
||||
}
|
||||
}
|
||||
out.push(above);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// One copy of the knockout shape of `c` in the definitions: `c` at full object opacity
|
||||
/// without its own mask, unless those define its shape. Returns its id.
|
||||
fn knockout_shape(&mut self, c: &Node) -> String {
|
||||
let shape = if c.knockout_shape {
|
||||
c.clone()
|
||||
} else {
|
||||
let mut s = c.clone().with_mask(None);
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
let anonymous = std::mem::replace(&mut self.anonymous, true);
|
||||
let art = self.detached(2, |w| w.node(&shape));
|
||||
self.anonymous = anonymous;
|
||||
let id = self.fresh_id("knockout-art");
|
||||
self.def(1, &format!("<g id=\"{id}\">"));
|
||||
self.defs.push_str(&art);
|
||||
self.def(1, "</g>");
|
||||
id
|
||||
}
|
||||
|
||||
/// A mask that is 1 − the knockout shapes `ids` ([`Self::knockout_shape`]): white, then the
|
||||
/// shapes painted black. `region` is the attributes of the mask's region
|
||||
/// ([`Self::region_attrs`]), which the filter that paints the shapes black shares. Returns
|
||||
/// the mask id.
|
||||
fn knockout_mask(&mut self, ids: &[&str], 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\" {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_filters.insert(region.to_string(), f.clone());
|
||||
f
|
||||
}
|
||||
};
|
||||
let mid = self.fresh_id("knockout");
|
||||
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})\">"));
|
||||
for id in ids {
|
||||
self.def(3, &format!("<use xlink:href=\"#{id}\"/>"));
|
||||
}
|
||||
self.def(2, "</g>");
|
||||
self.def(1, "</mask>");
|
||||
mid
|
||||
}
|
||||
|
||||
/// The pieces of a partly transparent mesh-warped image (`elements`) as the elements of a
|
||||
/// knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece is drawn
|
||||
/// through one mask of where the pieces above it that reach it don't cover
|
||||
@@ -683,42 +804,6 @@ 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). `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\" {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_filters.insert(region.to_string(), f.clone());
|
||||
f
|
||||
}
|
||||
};
|
||||
let shape = if c.knockout_shape {
|
||||
c.clone()
|
||||
} else {
|
||||
let mut s = c.clone().with_mask(None);
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
let anonymous = std::mem::replace(&mut self.anonymous, true);
|
||||
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} {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>");
|
||||
self.def(1, "</mask>");
|
||||
mid
|
||||
}
|
||||
|
||||
/// An object, inside `<a>` when it links to a URL (Attributes panel).
|
||||
pub(super) fn node(&mut self, n: &Node) {
|
||||
// Ruler guides are not written, not even as a link.
|
||||
|
||||
@@ -262,3 +262,93 @@ fn a_mask_region_covers_a_blend_as_written() {
|
||||
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}");
|
||||
}
|
||||
|
||||
/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an
|
||||
/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape.
|
||||
fn knockout_squares(n: usize, knockout_shape: bool) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let backdrop = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), [230, 200, 50]);
|
||||
add(&mut d, backdrop);
|
||||
let colors = [[255, 0, 0], [0, 200, 0], [0, 0, 255]];
|
||||
let squares = (0..n)
|
||||
.map(|i| {
|
||||
let x = (i % 20) as f64 * 2.0;
|
||||
let mut s = rect(&mut d, Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]);
|
||||
(s.opacity, s.knockout_shape) = (0.5, knockout_shape);
|
||||
std::sync::Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(d.alloc_id(), squares);
|
||||
group.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, group);
|
||||
d
|
||||
}
|
||||
|
||||
/// The deepest an element of `svg` nests, counting the root element as 1.
|
||||
fn element_depth(svg: &str) -> usize {
|
||||
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
|
||||
xml.descendants().filter(|n| n.is_element()).map(|n| n.ancestors().filter(|a| a.is_element()).count()).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The elements of a knockout group are siblings, each in one mask of the overlapping elements
|
||||
/// above it, however many there are: 200 translucent elements stay well within the nesting the
|
||||
/// web build reads (34 levels), and the file reads back with nothing merged. Where the elements'
|
||||
/// shapes are the area they paint, the file draws as the canvas does.
|
||||
#[test]
|
||||
fn a_knockout_group_of_many_translucent_elements_stays_shallow() {
|
||||
for knockout_shape in [false, true] {
|
||||
let d = knockout_squares(200, knockout_shape);
|
||||
let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default());
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(svg.matches("<mask").count(), 199);
|
||||
// Each shape is written once.
|
||||
assert_eq!(svg.matches("<use ").count(), 200 * 199 / 2);
|
||||
let depth = element_depth(&svg);
|
||||
assert!(depth < 12, "{depth}");
|
||||
let (back, notes) = vectorcraft_svg::import_with_report(&svg).unwrap();
|
||||
assert!(!notes.contains(&vectorcraft_doc::depth::merged_note()), "{notes:?}");
|
||||
assert!(back.layers[0].children().is_some_and(|c| !c.is_empty()));
|
||||
if !knockout_shape {
|
||||
let (file, canvas) = (resvg_render(&svg, 200, 200), render_artboard(&d));
|
||||
for (x, y) in [(30, 100), (100, 100), (150, 100), (10, 10)] {
|
||||
assert!(
|
||||
within(file.over_white(x, y), canvas.over_white(x, y), 3),
|
||||
"{x},{y}: {:?} {:?}",
|
||||
file.over_white(x, y),
|
||||
canvas.over_white(x, y)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the masks of a knockout group would use more than 65,536 shapes, the group is written as
|
||||
/// a plain group, with a warning.
|
||||
#[test]
|
||||
fn a_knockout_group_too_large_to_mask_is_a_plain_group_with_a_warning() {
|
||||
// 400 elements that all overlap: 79,800 shapes in their masks.
|
||||
let d = knockout_squares(400, false);
|
||||
let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default());
|
||||
assert!(warnings.iter().any(|w| w.contains("too many overlapping elements")), "{warnings:?}");
|
||||
assert!(!svg.contains("<mask"));
|
||||
assert!(element_depth(&svg) < 12);
|
||||
}
|
||||
|
||||
/// Elements that don't overlap need no masks of each other.
|
||||
#[test]
|
||||
fn knockout_masks_hold_only_the_elements_that_overlap() {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let squares = (0..3)
|
||||
.map(|i| {
|
||||
let x = f64::from(i) * 60.0;
|
||||
let mut s = rect(&mut d, Rect::new(x, 0.0, x + 40.0, 40.0), [0, 0, 255]);
|
||||
s.opacity = 0.5;
|
||||
std::sync::Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(d.alloc_id(), squares);
|
||||
group.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, group);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(!svg.contains("<mask"), "{svg}");
|
||||
}
|
||||
|
||||
@@ -171,14 +171,20 @@ mod tests {
|
||||
app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap();
|
||||
assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None));
|
||||
|
||||
// A PDF export of a knockout group and of type in a font that isn't installed.
|
||||
// A PDF export of a knockout group whose soft masks would draw a brushed stroke, and of type
|
||||
// in a font that isn't installed.
|
||||
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
let lower = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
|
||||
let upper = app.run("shape.rectangle", json!({"x": 25, "y": 15, "width": 30, "height": 20})).unwrap()["id"].clone();
|
||||
app.run("brush.apply", json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
|
||||
app.run("object.setProps", json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
|
||||
app.run("select.set", json!({"ids": [lower, upper]})).unwrap();
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run("transparency.set", json!({"knockout": "on"})).unwrap();
|
||||
let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap();
|
||||
let notes = notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert!(notes.iter().any(|n| n.contains("brushed strokes")), "{r}");
|
||||
assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), ¬es[0]));
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
}
|
||||
|
||||
+6
-2
@@ -2512,8 +2512,12 @@ neutral, the default). In a knockout group each child hides what it covers of th
|
||||
pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the
|
||||
object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and
|
||||
`transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are
|
||||
saved with the document and undoable, and `transparency.info` reports them. PDF and SVG write knockout groups as
|
||||
soft-masked groups with the same look (the PDF export reports a warning).
|
||||
saved with the document and undoable, and `transparency.info` reports them. PDF writes a knockout group as a PDF
|
||||
knockout group, which PDF import reads back as one. Where an element knocks out less than the area it paints
|
||||
(`knockoutShape: true` with opacity or a mask, or translucent paint), PDF draws each element through one soft mask of
|
||||
the overlapping elements above it, as SVG does for every knockout group of translucent art. A group whose masks would
|
||||
hold more than 65,536 objects (PDF) or shapes (SVG), or that has more than 2,048 elements, is written as a PDF knockout
|
||||
group or a plain SVG group instead, and the export reports a warning.
|
||||
|
||||
Groups are not isolated unless Isolate Blending is on: a blend mode inside a group with opacity, a blend mode, an
|
||||
opacity mask, knockout or a clip reaches the art below the group, on screen and in PDF (a knockout group's elements
|
||||
|
||||
@@ -1578,6 +1578,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`).
|
||||
On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.
|
||||
Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports.
|
||||
PDF export writes a knockout group as one PDF knockout group (`/K true`, with `/I true` when the group isolates; ISO 32000-1, 11.4.6.3), so a knockout group of any size opens again in VectorCraft as a knockout group, and the form nesting of the file does not grow with the number of elements. When an element's opacity or mask defines its knockout shape, or it paints translucently, each element is drawn through one soft mask of the overlapping elements above it instead; past 65,536 objects in a group's masks (or 2,048 elements) the group is a PDF knockout group, with a warning that its translucent elements knock out fully. SVG export draws each element of a knockout group through one mask of the overlapping elements above it, and the masks refer to one copy of each element's shape, so the elements stay siblings and the file nests no deeper with more elements; past 65,536 shapes in a group's masks (or 2,048 elements) the group is written as a plain group, with a warning. Evidence: `pdf/src/tests/blend.rs`, `svg/tests/it/blend_export.rs`, `pdf/tests/gradient_forms.rs` (a gradient in a knockout group reads back in place).
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; ten generated style libraries (including Game HUD, Status Effects, Painted and Sketched, whose seven styles each repaint a solid fill with one watercolor, dry brush, pastel, charcoal or ink effect, and Textures) save and load as `.vcstyles`.
|
||||
The built-in Textures graphic style library (`graphicStyle.addFromLibrary {library: "textures"}`) has eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The help page names new graphic styles for textures ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html)) without naming them, so the styles, their names and their settings are VectorCraft's own (`tests::stylelib::textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open`).
|
||||
In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one.
|
||||
|
||||
Reference in New Issue
Block a user