Files
vectorcraft/crates/engine/src/tests_knockout.rs
bflatasticandClaude Opus 5.5 4304a7c0b1 M4.31: Save PDF dialog and document.exportPdf with PDF settings
PdfSettings (serde, camelCase) is the Save PDF model: Standard, Compatibility
PDF 1.4-2.0, General (Preserve editing, Thumbnails, Fast web view, View after
saving, Create layers), Compression, Marks and Bleeds, Output, Advanced and
Security. PdfOptions = settings + pages, title and date.

document.exportPdf {path?, preset?, artboard?|artboards?|range?, standard?,
compatibility?, ...sections} -> {path|dataBase64, bytes, warnings} applies the
options over the preset (null keeps the preset's value); PDF/X, passwords,
PDF/A-2b at 2.0 and out-of-range values are refused; options accepted but not
applied yet come back as warnings. document.export {format: pdf} and
document.formats take and list the same options.

The Save PDF dialog (file.export.pdf, ui.savePdfDialog) has Preset,
Standard and Compatibility on top and a section list; it runs the export
before asking for a path, so bad options never open a save dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:27 +07:00

178 lines
8.4 KiB
Rust
Raw Permalink 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 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::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);
// 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"));
}