Files
craft-libs/crates/prepress/tests/marks.rs
T
1fc4c17fff prepress: printer's marks (#754)
* prepress: printer's marks

Marks::new lays out crop marks, bleed marks, registration targets and
process color bars around a trim box, with the sheet that holds them and
room for a page-information line. Marks::draw paints them onto a krilla
surface: lines in the registration color (Separation /All), bars in
DeviceCMYK. ISO 32000-2 14.11.3, 14.11.2, 8.6.6.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* prepress: keep the info line clear of the bottom target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* prepress: clear targets only when drawn; cap line width at a third of the mark

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 06:44:39 +01:00

173 lines
8.5 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.
//! Printer's marks: layout from `Marks::new`, and `Marks::draw` rendered back through `plates`.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use craft_pdf::write::backend::SerializeSettings;
use craft_pdf::write::backend::geom::Size;
use craft_pdf::write::backend::page::PageSettings;
use craft_pdf::write::{Metadata, Writer};
use craft_prepress::marks::{BAR_PATCHES, Rect};
use craft_prepress::{InkId, MarkOptions, Marks, MarksError, Options, Process, plates};
/// A 200 × 100 pt trim at (100, 100).
const TRIM: Rect = Rect { x0: 100.0, y0: 100.0, x1: 300.0, y1: 200.0 };
fn only(f: impl FnOnce(&mut MarkOptions)) -> MarkOptions {
let mut o = MarkOptions { crop: false, bleed: false, registration: false, color_bars: false, ..MarkOptions::default() };
f(&mut o);
o
}
fn inside(r: Rect, (x, y): (f64, f64)) -> bool {
(r.x0..=r.x1).contains(&x) && (r.y0..=r.y1).contains(&y)
}
#[test]
fn the_sheet_holds_the_marks_and_clears_the_bleed() {
let m = Marks::new(TRIM, TRIM, &MarkOptions::default()).unwrap();
assert_eq!((m.gap, m.sheet), (6.0, Rect::new(76.0, 76.0, 324.0, 224.0)));
let bleed = Rect::new(91.0, 100.0, 300.0, 200.0);
let m = Marks::new(TRIM, bleed, &MarkOptions::default()).unwrap();
assert_eq!((m.gap, m.sheet), (9.0, Rect::new(73.0, 73.0, 327.0, 227.0)), "the gap widens to clear a 9 pt bleed");
for p in m.lines.iter().flatten() {
assert!((inside(m.sheet, *p) && !inside(m.trim, *p)) || on_cross(&m, *p), "{p:?} sits in the slug");
}
for p in &m.patches {
assert!(inside(m.sheet, (p.rect.x0, p.rect.y0)) && inside(m.sheet, (p.rect.x1, p.rect.y1)));
assert!(p.rect.y1 <= m.bleed.y0 - m.gap + 1e-9, "patches stay clear of the bleed");
}
}
/// Registration crosses lie in the slug too; this only lets their arms touch the sheet edge.
fn on_cross(m: &Marks, p: (f64, f64)) -> bool {
m.circles.iter().any(|((x, y), r)| (p.0 - x).abs() <= r * 1.4 + 1e-9 && (p.1 - y).abs() <= r * 1.4 + 1e-9)
}
#[test]
fn crop_marks_run_outward_from_the_gap_on_the_trim_lines() {
let m = Marks::new(TRIM, TRIM, &only(|o| o.crop = true)).unwrap();
assert_eq!(m.lines.len(), 8);
assert!(m.lines.contains(&[(94.0, 100.0), (76.0, 100.0)]), "top left, horizontal");
assert!(m.lines.contains(&[(100.0, 94.0), (100.0, 76.0)]), "top left, vertical");
assert!(m.lines.contains(&[(306.0, 200.0), (324.0, 200.0)]), "bottom right, horizontal");
assert!(m.lines.contains(&[(300.0, 206.0), (300.0, 224.0)]), "bottom right, vertical");
assert!(m.circles.is_empty() && m.patches.is_empty());
}
#[test]
fn bleed_marks_appear_only_when_the_bleed_passes_the_trim() {
let opts = only(|o| o.bleed = true);
assert!(Marks::new(TRIM, TRIM, &opts).unwrap().lines.is_empty());
let m = Marks::new(TRIM, grown(TRIM, 9.0), &opts).unwrap();
assert_eq!(m.lines.len(), 8);
assert!(m.lines.contains(&[(91.0, 91.0), (82.0, 91.0)]), "half-length, on the bleed line: {:?}", m.lines);
assert!(m.lines.contains(&[(309.0, 209.0), (309.0, 218.0)]));
}
fn grown(r: Rect, by: f64) -> Rect {
Rect::new(r.x0 - by, r.y0 - by, r.x1 + by, r.y1 + by)
}
#[test]
fn a_registration_target_sits_mid_band_on_each_side() {
let m = Marks::new(TRIM, TRIM, &only(|o| o.registration = true)).unwrap();
let centres: Vec<_> = m.circles.iter().map(|(c, r)| (*c, *r)).collect();
assert_eq!(centres, [((200.0, 85.0), 6.0), ((200.0, 215.0), 6.0), ((85.0, 150.0), 6.0), ((315.0, 150.0), 6.0)]);
assert_eq!(m.lines.len(), 8, "a cross through each");
}
#[test]
fn colour_bars_fit_between_the_crop_marks_and_the_middle() {
// Half the 200 pt width, less a mark length: 82 pt, so 7 patches of 10.8.
assert_eq!(Marks::new(TRIM, TRIM, &only(|o| o.color_bars = true)).unwrap().patches.len(), 7);
// Less the top target's arm too: 73.2 pt, so 6.
let m = Marks::new(TRIM, TRIM, &only(|o| (o.color_bars, o.registration) = (true, true))).unwrap();
assert_eq!(m.patches.len(), 6);
let first = m.patches.first().unwrap();
assert_eq!(first.cmyk, BAR_PATCHES[0]);
assert!((first.rect.x0 - 118.0).abs() < 1e-9 && (first.rect.width() - 10.8).abs() < 1e-9);
assert!(m.patches.iter().all(|p| p.rect.y0 >= m.sheet.y0 && p.rect.y1 <= TRIM.y0 - m.gap));
assert!(m.patches.last().unwrap().rect.x1 < 200.0 - 6.0 * 1.4, "short of the top target");
let narrow = Rect::new(0.0, 0.0, 40.0, 40.0);
assert!(Marks::new(narrow, narrow, &only(|o| o.color_bars = true)).unwrap().patches.is_empty());
}
#[test]
fn the_info_line_sits_in_the_bottom_band() {
let m = Marks::new(TRIM, TRIM, &MarkOptions::default()).unwrap();
let (x, y) = m.info.origin;
assert_eq!((x, m.info.size), (118.0, 7.0));
assert!(y > TRIM.y1 + m.gap && y < m.sheet.y1);
let ((cx, _), r) = m.circles[1];
assert!(x + m.info.max_width <= cx - r * 1.4 + 1e-9, "the line stops short of the bottom target");
let crop_only = Marks::new(TRIM, TRIM, &only(|o| o.crop = true)).unwrap();
assert_eq!(crop_only.info.max_width, 82.0, "with no target, the line may reach the middle");
}
#[test]
fn bad_options_and_boxes_are_errors() {
let e = |o: MarkOptions| Marks::new(TRIM, TRIM, &o).unwrap_err();
assert_eq!(e(only(|_| {})), MarksError::NoMarks);
for (o, what) in [
(MarkOptions { offset: -1.0, ..MarkOptions::default() }, "offset"),
(MarkOptions { length: 0.0, ..MarkOptions::default() }, "length"),
(MarkOptions { length: f64::NAN, ..MarkOptions::default() }, "length"),
(MarkOptions { line_width: 145.0, ..MarkOptions::default() }, "line width"),
(MarkOptions { line_width: 6.5, ..MarkOptions::default() }, "line width"),
] {
assert!(matches!(e(o), MarksError::Size { what: w, .. } if w == what), "{what}");
}
let opts = MarkOptions::default();
assert_eq!(Marks::new(Rect::new(10.0, 0.0, 0.0, 10.0), TRIM, &opts).unwrap_err(), MarksError::Box("trim"));
assert_eq!(Marks::new(TRIM, Rect::new(0.0, f64::INFINITY, 1.0, 1.0), &opts).unwrap_err(), MarksError::Box("bleed"));
let huge = Rect::new(0.0, 0.0, 1e30, 10.0);
assert_eq!(Marks::new(huge, huge, &opts).unwrap_err(), MarksError::Box("sheet"));
}
/// `m` drawn on a sheet-sized page, rendered to plates at 72 dpi (one pixel per point).
fn render(m: &Marks) -> craft_prepress::Plates {
let mut writer = Writer::new(SerializeSettings::default(), Metadata::default());
let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(m.sheet.width() as f32, m.sheet.height() as f32).unwrap()));
let mut surface = page.surface();
m.draw(&mut surface);
surface.finish();
page.finish();
plates(&writer.finish().unwrap(), 0, 72.0, &Options::default()).unwrap()
}
#[test]
fn drawn_marks_print_on_every_plate_and_patches_on_their_own() {
let m = Marks::new(TRIM, TRIM, &MarkOptions { line_width: 3.0, ..MarkOptions::default() }).unwrap();
let p = render(&m);
let px = |(x, y): (f64, f64)| ((x - m.sheet.x0) as u32, (y - m.sheet.y0) as u32);
let ink = |id: Process, at: (f64, f64)| {
let (x, y) = px(at);
p.tint(InkId::process(id), x, y).unwrap()
};
let all = [Process::Cyan, Process::Magenta, Process::Yellow, Process::Black];
let crop = (85.0, 100.0);
assert!(all.iter().all(|&c| ink(c, crop) > 200), "a crop mark inks every plate");
let target = (200.0, 85.0);
assert!(all.iter().all(|&c| ink(c, target) > 200), "the registration cross inks every plate");
let cyan = m.patches.first().unwrap().rect;
let mid = ((cyan.x0 + cyan.x1) / 2.0, (cyan.y0 + cyan.y1) / 2.0);
assert!(ink(Process::Cyan, mid) > 250, "the cyan patch inks cyan");
assert!([Process::Magenta, Process::Yellow, Process::Black].iter().all(|&c| ink(c, mid) == 0), "and nothing else");
assert!(all.iter().all(|&c| ink(c, (200.0, 150.0)) == 0), "the trim area is left alone");
}
/// A trim at the origin puts the sheet origin at negative coordinates; a bleed moves the marks out.
#[test]
fn drawn_marks_follow_the_sheet_origin_and_the_bleed() {
let trim = Rect::new(0.0, 0.0, 200.0, 100.0);
let m = Marks::new(trim, grown(trim, 9.0), &MarkOptions { line_width: 3.0, ..MarkOptions::default() }).unwrap();
assert_eq!((m.sheet.x0, m.sheet.y0), (-27.0, -27.0));
let p = render(&m);
let tint = |id: Process, (x, y): (f64, f64)| p.tint(InkId::process(id), (x - m.sheet.x0) as u32, (y - m.sheet.y0) as u32).unwrap();
assert!(tint(Process::Black, (-18.0, 0.0)) > 200, "the top-left crop mark, past the bleed");
assert!(tint(Process::Black, (-13.0, -9.0)) > 200, "the top-left bleed mark");
assert_eq!(tint(Process::Black, (-5.0, 0.0)), 0, "nothing inside the bleed");
}