Files
vectorcraft/crates/engine/src/tests_registration.rs
T
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

173 lines
8.8 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.
//! The built-in [Registration] swatch (every plate, PDF `/Separation /All`), Object → Create Trim
//! Marks (Registration, every artboard, Japanese marks) and Effect → Crop Marks.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::Affine;
use vectorcraft_render::proof::ProofSetup;
use vectorcraft_render::{RenderOptions, Renderer};
use super::*;
use crate::tests_colormgmt::GLOBAL;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// The 4× render of `doc` (400×400 px), on white, showing `plates` only when given.
fn render(doc: &Document, plates: Option<&[&str]>) -> vectorcraft_render::Rendered {
let proof = plates.map(|p| ProofSetup { separations: Some(p.iter().map(|n| n.to_string()).collect()), ..Default::default() });
let mut r = Renderer::new();
r.threads = 0;
r.render(doc, 400, 400, Affine::scale(4.0), &RenderOptions { background: Some([255, 255, 255, 255]), proof, ..Default::default() })
}
fn dark(p: [u8; 4]) -> bool {
p[0] < 90 && p[1] < 90 && p[2] < 90
}
#[test]
fn registration_is_a_reserved_swatch() {
let mut s = session();
let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})));
run(&mut s, "paint.setStroke", json!({"swatch": REGISTRATION}));
let st = node(&s, a).appearance.stroke().unwrap().paint.clone();
assert!(st.is_registration() && st == Paint::registration(), "{st:?}");
// Listed after None, global; it can't be deleted, edited, moved, duplicated or merged.
let list = run(&mut s, "swatch.list", json!({}));
assert_eq!(list["swatches"][0]["kind"], "none");
assert_eq!((list["swatches"][1]["name"].clone(), list["swatches"][1]["global"].clone()), (json!(REGISTRATION), json!(true)));
for (cmd, p) in [
("swatch.delete", json!({"name": REGISTRATION})),
("swatch.edit", json!({"name": REGISTRATION, "color": "#ff0000"})),
("swatch.move", json!({"name": REGISTRATION, "to": 5})),
("swatch.duplicate", json!({"name": REGISTRATION})),
("swatch.merge", json!({"names": ["White", REGISTRATION]})),
("swatch.new", json!({"name": REGISTRATION, "color": "#ff0000"})),
] {
let r = s.execute(cmd, &p);
assert!(r.is_err() || cmd == "swatch.new", "{cmd}");
if cmd == "swatch.new" {
assert_ne!(r.unwrap()["name"], REGISTRATION, "the name is taken");
}
}
assert!(s.doc().unwrap().doc.swatch(REGISTRATION).is_some());
// Unused swatches never list it.
assert!(!run(&mut s, "swatch.unused", json!({}))["names"].as_array().unwrap().iter().any(|n| n == REGISTRATION));
}
#[test]
fn trim_marks_print_on_every_plate() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Spot Ink", "color": {"c": 0, "m": 60, "y": 90, "k": 0}, "global": true}));
run(&mut s, "swatch.setSpot", json!({"name": "Spot Ink"}));
run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30.125, "width": 40, "height": 40}));
run(&mut s, "paint.setStroke", json!({"none": true}));
let g = id_of(&run(&mut s, "object.createTrimMarks", json!({"weight": 1})));
let n = node(&s, g);
assert_eq!(n.children().unwrap().len(), 8);
assert!(n.children().unwrap().iter().all(|c| c.appearance.stroke().unwrap().paint.is_registration()));
let doc = s.doc().unwrap().doc.clone();
// The top-left mark runs from x = 21 to 3 at y = 30.125: 100% ink on each plate.
for plate in ["Cyan", "Magenta", "Yellow", "Black", "Spot Ink"] {
let p = render(&doc, Some(&[plate])).pixel(48, 120);
assert!(dark(p), "{plate}: {p:?}");
}
// A plain black mark would print on the black plate only.
run(&mut s, "paint.setStroke", json!({"ids": [n.children().unwrap()[0].id.0], "color": {"c": 0, "m": 0, "y": 0, "k": 100}}));
let doc = s.doc().unwrap().doc.clone();
assert!(!dark(render(&doc, Some(&["Cyan"])).pixel(48, 120)));
assert!(!dark(render(&doc, Some(&["Spot Ink"])).pixel(48, 120)));
}
#[test]
fn pdf_writes_registration_as_separation_all() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}));
run(&mut s, "object.createTrimMarks", json!({}));
let bytes = vectorcraft_pdf::export(
&s.doc().unwrap().doc,
&vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() },
)
.unwrap();
let pdf = String::from_utf8_lossy(&bytes);
assert!(pdf.contains("/Separation/All/DeviceCMYK"), "Registration prints on every plate");
}
#[test]
fn trim_marks_cover_every_artboard_and_take_the_japanese_style() {
let mut s = session();
run(&mut s, "artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 100}));
let r = run(&mut s, "object.createTrimMarks", json!({}));
let ids = r["ids"].as_array().unwrap().clone();
assert_eq!(ids.len(), 2, "one group per artboard when nothing is selected");
let b = s.doc().unwrap().doc.bounds_of(&[NodeId(ids[1].as_u64().unwrap())], false).unwrap();
assert!(b.x0 < 200.0 && b.x0 > 150.0 && b.x1 > 300.0, "{b:?}");
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.layers[0].children().unwrap().is_empty(), "one undo step");
// Japanese marks: double lines at the trim and bleed edges, and centre marks.
run(&mut s, "prefs.set", json!({"key": "japaneseCropMarks", "value": true}));
assert_eq!(s.crop_mark_style(), vectorcraft_doc::marks::MarkStyle::Japanese);
assert!(s.execute("object.createTrimMarks", &json!({"allArtboards": false})).is_err(), "nothing selected to mark");
let r = run(&mut s, "object.createTrimMarks", json!({}));
let first = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
let lines = first.children().unwrap();
assert_eq!(lines.len(), 24);
// At the top-left corner of the first artboard: horizontal lines at y = 0 and y = -bleed.
let ys: Vec<f64> = lines[..4].iter().filter_map(|l| l.path_data().and_then(|p| p.bounds())).filter(|b| b.height() < 1e-9).map(|b| b.y0).collect();
assert_eq!(ys.len(), 2);
assert!(ys.iter().any(|y| y.abs() < 1e-9) && ys.iter().any(|y| (y + 3.0 * 72.0 / 25.4).abs() < 1e-6), "{ys:?}");
// An explicit style wins over the preference.
let r = run(&mut s, "object.createTrimMarks", json!({"style": "roman"}));
assert_eq!(node(&s, NodeId(r["ids"][0].as_u64().unwrap())).children().unwrap().len(), 8);
assert!(s.execute("object.createTrimMarks", &json!({"style": "western"})).is_err());
}
#[test]
fn crop_marks_effect_follows_the_object() {
let mut s = session();
let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30.125, "width": 40, "height": 40})));
run(&mut s, "paint.setStroke", json!({"none": true}));
run(&mut s, "effect.apply", json!({"effect": "cropMarks"}));
let fx = node(&s, a).appearance.effects[0].clone();
assert_eq!((fx.id.as_str(), fx.params["style"].as_str()), ("cropMarks", Some("roman")), "the style comes from the preferences");
// The marks render outside the object (0.3 pt at 4×: about a pixel).
let mark = |s: &Session, x: u32| render(&s.doc().unwrap().doc, None).pixel(x, 120);
assert!(mark(&s, 48)[0] < 200, "{:?}", mark(&s, 48));
// Moved, the marks move with it.
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
assert_eq!(mark(&s, 48)[0], 255, "nothing left behind");
assert!(mark(&s, 88)[0] < 200);
// SVG export and Expand Appearance draw the marks as paths.
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert_eq!(svg.matches("<path").count(), 9, "{svg}");
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, a);
assert!(n.appearance.effects.is_empty() && n.children().unwrap().len() == 2, "a group of the object and its marks");
assert_eq!(n.children().unwrap()[1].children().unwrap().len(), 8);
// Japanese crop marks.
let b = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "cropMarks", "params": {"style": "japanese"}}));
let art = vectorcraft_render::effects::crop_marks_art(&node(&s, b)).unwrap();
assert_eq!(art.children().unwrap()[1].children().unwrap().len(), 24);
}