PDF/SVG export: knockout groups nest no deeper with more elements

A knockout group was written as a chain of soft masks, one inside the
next for each element, so a group of about 200 elements made a PDF that
VectorCraft's own PDF import refused (form nesting past 50) and an SVG
that the web build refused as too deep.

- PDF writes a knockout group as one transparency group with /K true,
  with /I true when the group isolates, and PDF import reads it back as
  a knockout group. An element with several paints is drawn as one
  group inside it, so its own fills and strokes do not knock each other
  out.
- Where an element knocks out less than the area it paints (its 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. Past 65,536 objects in a group's masks, or 2,048 elements,
  the group is a PDF knockout group, with a warning.
- SVG draws each element through one mask of the overlapping elements
  above it, built from one shared copy of each element's shape, so the
  elements stay siblings. Past 65,536 shapes in a group's masks, or
  2,048 elements, the group is written as a plain group, with a warning.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 12:21:39 -08:00
parent 08b6cfd3e8
commit 88b587baa6
10 changed files with 611 additions and 116 deletions
+7 -6
View File
@@ -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"));
}
+1 -1
View File
@@ -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_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, MEASURES};
pub(crate) use writer::{Sheet, Writer};
pub(crate) struct Exporter<'a> {
+209 -60
View File
@@ -57,70 +57,119 @@ 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;
}
// 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 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();
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);
if elements.is_empty() {
return;
}
self.measured_node(s, first, page, false, Some(measured));
for (c, m) in rest {
s.pop();
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.
if several && !elements.iter().skip(1).all(|(c, _)| shape_is_coverage(c)) {
if let Some(above) = knockout_masks(&elements) {
if isolated {
s.push_isolated();
}
for ((c, m), above) in elements.iter().zip(above) {
if above.is_empty() {
self.measured_node(s, c, page, false, Some(m));
} else {
let shapes: Vec<_> = above.into_iter().map(|j| &elements[j]).collect();
let mask = self.knockout_mask(s, &shapes, page);
s.push_mask(mask);
self.measured_node(s, c, page, false, Some(m));
s.pop();
}
}
if isolated {
s.pop();
}
return;
}
self.warn(KNOCKOUT_SHAPES);
}
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();
}
}
/// 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
};
/// 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).
fn knockout_mask(&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);
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();
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)
@@ -177,23 +226,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),
@@ -214,7 +247,7 @@ impl Exporter<'_> {
}
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
self.measured_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
@@ -675,3 +708,119 @@ 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;
/// For each element of a knockout group (with its box), the indices of the elements above it whose
/// boxes overlap its own. `None` when there are more than [`MAX_MASKED_ELEMENTS`] elements or the
/// masks would draw more than [`MAX_KNOCKOUT_MASK_OBJECTS`] objects.
fn knockout_masks(elements: &[(&Node, Measured)]) -> Option<Vec<Vec<usize>>> {
if elements.len() > MAX_MASKED_ELEMENTS {
return None;
}
let counts: Vec<usize> = elements.iter().map(|(c, _)| c.count_with_masks()).collect();
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)) 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))
{
objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS)?;
above.push(j);
}
}
out.push(above);
}
Some(out)
}
/// 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,
}
}
+5
View File
@@ -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));
+162
View File
@@ -97,3 +97,165 @@ 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]);
}
}
/// 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}");
}
/// 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]);
}
+1 -3
View File
@@ -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);
+129 -44
View File
@@ -156,6 +156,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.
@@ -443,20 +455,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();
@@ -565,8 +596,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);
@@ -586,41 +619,101 @@ 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, &region);
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, &region);
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.body.push_str(&body);
self.line("</g>");
}
}
/// 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 {
/// 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 => {
@@ -632,21 +725,13 @@ impl Writer<'_> {
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);
for id in ids {
self.def(3, &format!("<use xlink:href=\"#{id}\"/>"));
}
self.def(2, "</g>");
self.def(1, "</mask>");
mid
+90
View File
@@ -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}");
}
+6 -2
View File
@@ -2428,8 +2428,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
+1
View File
@@ -1511,6 +1511,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.