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"));
}