mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
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.
179 lines
8.5 KiB
Rust
179 lines
8.5 KiB
Rust
//! 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"));
|
||
}
|