Files
vectorcraft/crates/engine/src/tests_registration.rs
T
bflatasticandClaude Opus 5.5 c1df6f0621 M3.77: [Registration] swatch on every plate, trim marks on every artboard, Japanese marks and live Crop Marks
[Registration] is a built-in swatch that prints 100% on every plate: Separations
Preview shows it on each process and spot plate and PDF export writes it as
/Separation /All. It can't be edited, moved, duplicated, merged or deleted;
swatch.list shows it after None and the Swatches panel applies it as a swatch.
Object > Create Trim Marks strokes the marks in Registration and marks every
artboard when nothing is selected ({style?, allArtboards?}). The preference
japaneseCropMarks (General) switches to Japanese marks: double lines at the
trim and bleed edges plus centre marks. Effect > Crop Marks (cropMarks) is a
live effect whose marks follow the object; rendering, SVG/PDF export and
Expand Appearance draw them. The mark geometry lives in doc::marks.

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

171 lines
8.7 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 { compress: false, created: Some(0), ..Default::default() })
.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);
}