mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
New Document.page_isolate / page_knockout, set by
transparency.togglePageIsolatedBlending / togglePageKnockoutGroup {value?}
(one undo step each, saved with the document, reported by
transparency.info) and the Transparency panel menu's check items. The page
renders as an isolated group (top-level blend modes no longer blend with
what lies under the page) and/or a knockout group whose elements are the
layers (neutral layers pass their objects through). PDF and SVG wrap the
page content in an isolated group and write page knockout as soft masks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
8.5 KiB
Rust
178 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 super::*;
|
||
|
||
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_and_svg_write_knockout_groups_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 { compress: false, ..Default::default() }).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);
|
||
// 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 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).contains("/Luminosity"));
|
||
}
|