Files
vectorcraft/crates/engine/src/tests/knockout.rs
T
dotCooCoo 88b587baa6 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.
2026-11-13 12:21:39 -08:00

179 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Knockout groups, the knockout shape and the page group (M3.46–M3.48): commands, undo, files
//! (old bools load) and the PDF and SVG output.
use serde_json::{Value, json};
use vectorcraft_doc::Knockout;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn node(s: &Session, id: u64) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone()
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// A group of two overlapping 50% rectangles, selected. Returns the group id.
fn knockout_group(s: &mut Session) -> u64 {
let a = run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone();
let b = run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 100, "height": 100}))["id"].clone();
run(s, "transparency.set", json!({"ids": [a, b], "opacity": 50}));
run(s, "select.set", json!({"ids": [a, b]}));
run(s, "object.group", json!({}))["id"].as_u64().unwrap()
}
#[test]
fn knockout_takes_three_states_names_and_bools() {
let mut s = session();
let g = knockout_group(&mut s);
assert_eq!(node(&s, g).knockout, Knockout::Neutral, "new groups are neutral");
for (param, want) in [
(json!("on"), Knockout::On),
(json!("Off"), Knockout::Off),
(json!("neutral"), Knockout::Neutral),
(json!(true), Knockout::On),
(json!(false), Knockout::Neutral),
] {
let before = steps(&s);
run(&mut s, "transparency.set", json!({"knockout": param}));
assert_eq!((node(&s, g).knockout, steps(&s)), (want, before + 1), "{param}");
}
run(&mut s, "transparency.set", json!({"knockout": "off"}));
let info = run(&mut s, "transparency.info", json!({}));
assert_eq!((info["knockout"].clone(), info["knockoutShape"].clone()), (json!("off"), json!(false)));
// A bad state changes nothing, also next to an opacity (which would go to an appearance item).
let before = steps(&s);
assert!(s.execute("transparency.set", &json!({"knockout": "maybe"})).is_err());
assert!(s.execute("transparency.set", &json!({"knockout": 3, "opacity": 20})).is_err());
assert!(s.execute("object.setProps", &json!({"knockout": "maybe"})).is_err());
assert_eq!((node(&s, g).knockout, node(&s, g).opacity, steps(&s)), (Knockout::Off, 1.0, before));
// Undo is one step per change.
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, g).knockout, Knockout::Neutral);
// The panel's click order.
assert_eq!([Knockout::On, Knockout::Neutral, Knockout::Off].map(Knockout::cycle), [Knockout::Neutral, Knockout::Off, Knockout::On]);
}
#[test]
fn knockout_shape_is_an_object_option() {
let mut s = session();
let g = knockout_group(&mut s);
let child = node(&s, g).children().unwrap()[1].id.0;
run(&mut s, "transparency.set", json!({"ids": [child], "knockoutShape": true}));
assert!(node(&s, child).knockout_shape && !node(&s, child).has_default_transparency());
let info = run(&mut s, "transparency.info", json!({"id": child}));
assert_eq!(info["knockoutShape"], true);
let mixed = run(&mut s, "transparency.info", json!({"ids": [child, g]}));
assert_eq!(mixed["knockoutShape"], Value::Null);
run(&mut s, "edit.undo", json!({}));
assert!(!node(&s, child).knockout_shape);
}
#[test]
fn page_group_toggles_are_undoable_document_settings() {
let mut s = session();
let doc = |s: &Session| (s.doc().unwrap().doc.page_isolate, s.doc().unwrap().doc.page_knockout);
let before = steps(&s);
assert_eq!(run(&mut s, "transparency.togglePageIsolatedBlending", json!({})), json!({"value": true}));
assert_eq!(run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})), json!({"value": true}));
assert_eq!((doc(&s), steps(&s)), ((true, true), before + 2));
// Setting the current value is not a change.
run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true}));
assert_eq!(steps(&s), before + 2);
let info = run(&mut s, "transparency.info", json!({}));
assert_eq!((info["pageIsolatedBlending"].clone(), info["pageKnockoutGroup"].clone()), (json!(true), json!(true)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(doc(&s), (true, false));
run(&mut s, "transparency.togglePageIsolatedBlending", json!({}));
assert_eq!(doc(&s), (false, false));
}
#[test]
fn knockout_settings_round_trip_and_old_bools_load() {
let mut s = session();
let g = knockout_group(&mut s);
let child = node(&s, g).children().unwrap()[0].id.0;
run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "off"}));
run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true}));
run(&mut s, "transparency.togglePageIsolatedBlending", json!({}));
run(&mut s, "transparency.togglePageKnockoutGroup", json!({}));
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&bytes).unwrap();
let (bg, bc) = (back.node(NodeId(g)).unwrap(), back.node(NodeId(child)).unwrap());
assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape, back.page_isolate, back.page_knockout), (Knockout::Off, Knockout::On, true, true, true));
// Defaults are not written.
let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap();
assert!(!text.contains("knockout") && !text.contains("page_isolate"), "{text}");
// Files from before the three states stored a bool: true is On, false is Neutral.
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
let mut patched = 0;
fn patch(v: &mut Value, n: &mut usize) {
match v {
Value::Object(m) => {
if let Some(k) = m.get_mut("knockout") {
*k = json!(*k == json!("on"));
*n += 1;
}
m.values_mut().for_each(|c| patch(c, n));
}
Value::Array(a) => a.iter_mut().for_each(|c| patch(c, n)),
_ => {}
}
}
patch(&mut v, &mut patched);
assert_eq!(patched, 2);
let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert_eq!((old.node(NodeId(g)).unwrap().knockout, old.node(NodeId(child)).unwrap().knockout), (Knockout::Neutral, Knockout::On));
}
#[test]
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();
run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "on"}));
let ko = s.doc().unwrap().doc.clone();
let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
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).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"));
let out = svg(&ko);
assert_eq!(out.matches("mask=\"url(#knockout-").count(), 1, "{out}");
assert!(out.contains("<feColorMatrix") && out.contains("isolation"), "{out}");
// A neutral group passes its children through: still one mask per element below the top one.
run(&mut s, "select.set", json!({"ids": [g]}));
let outer = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap();
run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "neutral"}));
run(&mut s, "transparency.set", json!({"ids": [outer], "knockout": "on"}));
assert_eq!(svg(&s.doc().unwrap().doc).matches("mask=\"url(#knockout-").count(), 1);
// The page group: an isolated group around the page content.
let mut page = (*plain).clone();
page.page_isolate = true;
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).replace(' ', "").contains("/Ktrue"));
}