From 1fc4c17fff58fe4aea7cee78755b84f0371ca5bc Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 00:44:39 -0500 Subject: [PATCH] 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 * prepress: keep the info line clear of the bottom target Co-Authored-By: Claude Opus 5.5 * prepress: clear targets only when drawn; cap line width at a third of the mark Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- crates/prepress/README.md | 7 + crates/prepress/src/ink.rs | 7 +- crates/prepress/src/lib.rs | 2 + crates/prepress/src/marks.rs | 340 +++++++++++++++++++++++++++++++++ crates/prepress/tests/marks.rs | 172 +++++++++++++++++ 5 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 crates/prepress/src/marks.rs create mode 100644 crates/prepress/tests/marks.rs diff --git a/crates/prepress/README.md b/crates/prepress/README.md index 49beb6351..46733e0b3 100644 --- a/crates/prepress/README.md +++ b/crates/prepress/README.md @@ -13,6 +13,13 @@ Prepress for the Craft apps: separations, ink limits and the ink manager. and the pixels and regions over a TAC limit. - `composite`: an sRGB preview of chosen plates. - `cms`: process color conversion into a CMYK printing condition through craft-cms. +- `marks`: printer's marks for a page with a slug. `Marks::new(trim, bleed, &MarkOptions)` lays + out crop marks, bleed marks (when the bleed passes the trim), a registration target on each + side and process color bars along the top, and returns the sheet (the media box that holds + them) and where a page-information line fits. `Marks::draw` paints them with craft-pdf's + writer: lines in the registration color, so they print on every plate, and the bars in + DeviceCMYK. It tags nothing; in a tagged document, call it inside a page artifact. The text of + the information line is the app's. - `flatten` (feature `flatten`): the display list a transparency flattener reads and writes: fills, text runs, images and transparency groups in page space, with ink colors and overprint diff --git a/crates/prepress/src/ink.rs b/crates/prepress/src/ink.rs index 6c8c267f1..02595aab2 100644 --- a/crates/prepress/src/ink.rs +++ b/crates/prepress/src/ink.rs @@ -407,7 +407,7 @@ impl InkColor { /// colorants. pub fn to_pdf_color(color: &InkColor, inks: &InkSet, cms: &dyn CmykConversion) -> Option { let tints = match color { - InkColor::All(tint) => return Some(separation(SeparationColorant::AllColorants, *tint, cmyk::Color::new(255, 255, 255, 255))), + InkColor::All(tint) => return Some(registration(*tint)), InkColor::None => return Some(separation(SeparationColorant::NoColorant, 0.0, cmyk::Color::new(0, 0, 0, 0))), InkColor::Tints(tints) if tints.is_empty() => return None, InkColor::Tints(tints) => tints, @@ -465,6 +465,11 @@ fn byte(v: f32) -> u8 { (unit(v) * 255.0).round() as u8 } +/// The registration color at `tint`: a `Separation /All` space, which marks every plate. +pub(crate) fn registration(tint: f32) -> PdfColor { + separation(SeparationColorant::AllColorants, tint, cmyk::Color::new(255, 255, 255, 255)) +} + fn separation(colorant: SeparationColorant, tint: f32, fallback: cmyk::Color) -> PdfColor { separation::Color::new(byte(tint), SeparationSpace::new(colorant, RegularColor::Cmyk(fallback))).into() } diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index 4100696b9..ca4598c95 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -10,9 +10,11 @@ pub mod coverage; #[cfg(feature = "flatten")] pub mod flatten; pub mod ink; +pub mod marks; pub mod plates; pub use cms::{CmsConversion, ProfileError}; pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name, to_pdf_color}; +pub use marks::{MarkOptions, Marks, MarksError}; pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/prepress/src/marks.rs b/crates/prepress/src/marks.rs new file mode 100644 index 000000000..b3c2423d4 --- /dev/null +++ b/crates/prepress/src/marks.rs @@ -0,0 +1,340 @@ +//! Printer's marks (ISO 32000-2 §14.11.3): crop and bleed marks, registration targets and color +//! bars in the slug outside the trim box (§14.11.2). [`Marks::new`] is pure geometry; +//! [`Marks::draw`] paints it with craft-pdf's writer, the lines in the registration color so +//! they print on every plate. +//! +//! Coordinates are points, y down, in the caller's space: the space `trim` and `bleed` are given +//! in. Every field of [`Marks`] stays in that space. [`Marks::draw`] alone shifts by +//! `-sheet.x0, -sheet.y0`, so the sheet's top left lands on the page origin; anything an app adds +//! to the slug, such as the information line, takes the same shift. + +use craft_pdf::write::backend::color::cmyk; +use craft_pdf::write::backend::geom::{Path, PathBuilder, Rect as PdfRect}; +use craft_pdf::write::backend::num::NormalizedF32; +use craft_pdf::write::backend::paint::{Fill, LineCap, LineJoin, Stroke}; +use craft_pdf::write::backend::surface::Surface; + +use crate::ink::registration; + +/// The largest offset, mark length or line width accepted, in points (two inches). +pub const MAX_MARK_SIZE: f64 = 144.0; + +/// An axis-aligned rectangle in page space; `x0 <= x1` and `y0 <= y1`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +impl Rect { + pub fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect { + Rect { x0, y0, x1, y1 } + } + + pub fn width(&self) -> f64 { + self.x1 - self.x0 + } + + pub fn height(&self) -> f64 { + self.y1 - self.y0 + } + + fn union(self, o: Rect) -> Rect { + Rect::new(self.x0.min(o.x0), self.y0.min(o.y0), self.x1.max(o.x1), self.y1.max(o.y1)) + } + + fn grow(self, by: f64) -> Rect { + Rect::new(self.x0 - by, self.y0 - by, self.x1 + by, self.y1 + by) + } + + fn is_valid(&self) -> bool { + [self.x0, self.y0, self.x1, self.y1].iter().all(|v| v.is_finite()) && self.x0 <= self.x1 && self.y0 <= self.y1 + } +} + +/// A point in page space. +pub type Point = (f64, f64); + +/// Which marks to draw, and their sizes in points. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkOptions { + /// Crop marks on the trim lines at each corner. + pub crop: bool, + /// Half-length marks on the bleed lines, when the bleed extends past the trim. + pub bleed: bool, + /// A circle and cross in the middle of each side. + pub registration: bool, + /// Process-colour patches along the top. + pub color_bars: bool, + /// Gap from the trim edge to where the marks start, widened to clear the bleed. At least 0. + pub offset: f64, + /// Length of a crop mark and the depth of the band the marks sit in. Above 0. + pub length: f64, + /// Stroke width. Above 0, and at most a third of `length`, so a registration circle's stroke + /// stays inside its band. + pub line_width: f64, +} + +impl Default for MarkOptions { + /// Every mark: 6 pt offset, 18 pt marks, 0.25 pt lines. + fn default() -> Self { + MarkOptions { crop: true, bleed: true, registration: true, color_bars: true, offset: 6.0, length: 18.0, line_width: 0.25 } + } +} + +impl MarkOptions { + fn any(&self) -> bool { + self.crop || self.bleed || self.registration || self.color_bars + } +} + +/// Why marks could not be laid out. +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum MarksError { + #[error("no printer's marks were asked for")] + NoMarks, + /// A size that is not finite, below its minimum, or above [`MAX_MARK_SIZE`]. + #[error("printer's marks {what} {value} is out of range")] + Size { what: &'static str, value: f64 }, + /// A trim or bleed box that is not finite or is inverted, or a sheet that would pass + /// [`MAX_SHEET`]. + #[error("printer's marks {0} box is not a valid rectangle")] + Box(&'static str), +} + +/// A filled colour-bar patch, in DeviceCMYK (`0.0..=1.0`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Patch { + pub rect: Rect, + pub cmyk: [f32; 4], +} + +/// Where an app's page-information line fits: the bottom band, past the crop marks. In the +/// caller's space, like the rest of [`Marks`]; subtract the sheet origin to draw on the page. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InfoLine { + /// The left end of the baseline. + pub origin: Point, + /// A font size that fits the band. + pub size: f64, + /// The widest the line may be before it reaches the bottom registration target. + pub max_width: f64, +} + +/// The furthest from the origin a sheet edge may be, in points: PDF's largest page side. +pub const MAX_SHEET: f64 = 14_400.0; + +/// One page's marks, laid out. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct Marks { + /// The trim box. + pub trim: Rect, + /// The bleed box, at least the trim. + pub bleed: Rect, + /// The page with its slug: the trim grown on every side to hold the marks. Use it as the + /// media box. + pub sheet: Rect, + /// Gap from the trim edge to the marks: the offset, widened to clear the bleed. + pub gap: f64, + /// Straight strokes: crop marks, bleed marks and the registration crosses. + pub lines: Vec<[Point; 2]>, + /// Registration circles: centre and radius. + pub circles: Vec<(Point, f64)>, + pub patches: Vec, + pub info: InfoLine, + /// Stroke width for lines and circles. + pub line_width: f64, +} + +/// The colour-bar patches: the four process inks, their two-colour overprints, then black at +/// 75, 50 and 25 per cent. +pub const BAR_PATCHES: [[f32; 4]; 10] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 1.0, 0.0], + [1.0, 0.0, 1.0, 0.0], + [1.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.75], + [0.0, 0.0, 0.0, 0.5], + [0.0, 0.0, 0.0, 0.25], +]; + +impl Marks { + /// Lays out `opts`' marks around `trim`. `bleed` is taken as at least the trim. + pub fn new(trim: Rect, bleed: Rect, opts: &MarkOptions) -> Result { + check(opts)?; + if !trim.is_valid() { + return Err(MarksError::Box("trim")); + } + if !bleed.is_valid() { + return Err(MarksError::Box("bleed")); + } + let bleed = trim.union(bleed); + let reach = (trim.x0 - bleed.x0).max(trim.y0 - bleed.y0).max(bleed.x1 - trim.x1).max(bleed.y1 - trim.y1); + let gap = opts.offset.max(reach); + let len = opts.length; + // The bars and the information line stop short of the top and bottom targets, when drawn. + let clear = if opts.registration { target_arm(len) } else { 0.0 }; + let sheet = trim.grow(gap + len); + if [sheet.x0, sheet.y0, sheet.x1, sheet.y1].iter().any(|v| v.abs() > MAX_SHEET) || sheet.width() > MAX_SHEET || sheet.height() > MAX_SHEET { + return Err(MarksError::Box("sheet")); + } + let mut m = Marks { + trim, + bleed, + sheet, + gap, + lines: Vec::new(), + circles: Vec::new(), + patches: Vec::new(), + info: info_line(trim, gap, len, clear), + line_width: opts.line_width, + }; + if opts.crop { + m.corner_marks(trim, len); + } + if opts.bleed && bleed != trim { + m.corner_marks(bleed, len / 2.0); + } + if opts.registration { + m.registration_targets(len); + } + if opts.color_bars { + m.color_bars(len, clear); + } + Ok(m) + } + + /// Two strokes per corner of `at`, running outward for `len` from the gap past the trim: + /// horizontal ones on `at`'s top and bottom lines, vertical ones on its sides. + fn corner_marks(&mut self, at: Rect, len: f64) { + let (t, g) = (self.trim, self.gap); + for y in [at.y0, at.y1] { + self.lines.push([(t.x0 - g, y), (t.x0 - g - len, y)]); + self.lines.push([(t.x1 + g, y), (t.x1 + g + len, y)]); + } + for x in [at.x0, at.x1] { + self.lines.push([(x, t.y0 - g), (x, t.y0 - g - len)]); + self.lines.push([(x, t.y1 + g), (x, t.y1 + g + len)]); + } + } + + /// A circle with a cross through it, centred in the band on each side. + fn registration_targets(&mut self, len: f64) { + let t = self.trim; + let mid = self.gap + len / 2.0; + let (r, arm) = (len / 3.0, target_arm(len)); + let (cx, cy) = (f64::midpoint(t.x0, t.x1), f64::midpoint(t.y0, t.y1)); + for (x, y) in [(cx, t.y0 - mid), (cx, t.y1 + mid), (t.x0 - mid, cy), (t.x1 + mid, cy)] { + self.circles.push(((x, y), r)); + self.lines.push([(x - arm, y), (x + arm, y)]); + self.lines.push([(x, y - arm), (x, y + arm)]); + } + } + + /// Square patches along the top band, from a mark length in from the left trim edge (past + /// the crop marks) up to the middle (short of the registration target); as many as fit. + fn color_bars(&mut self, len: f64, clear: f64) { + let t = self.trim; + let size = len * 0.6; + let y = t.y0 - self.gap - len + (len - size) / 2.0; + let room = t.width() / 2.0 - len - clear; + let fit = if room > 0.0 { (room / size).floor() as usize } else { 0 }; + for (i, c) in BAR_PATCHES.iter().take(fit).enumerate() { + let x = t.x0 + len + size * i as f64; + self.patches.push(Patch { rect: Rect::new(x, y, x + size, y + size), cmyk: *c }); + } + } + + /// Paints the marks onto `surface`: lines and circles stroked in the registration colour + /// (`Separation /All`), patches filled in DeviceCMYK. The surface's page should be + /// [`Marks::sheet`]-sized, with the sheet's top left at the origin; the marks are drawn in + /// that space. Nothing is tagged: in a tagged document, wrap the call in a page artifact. + pub fn draw(&self, surface: &mut Surface<'_>) { + let (dx, dy) = (-self.sheet.x0, -self.sheet.y0); + let at = |p: Point| ((p.0 + dx) as f32, (p.1 + dy) as f32); + let mut pb = PathBuilder::new(); + for [a, b] in &self.lines { + let (a, b) = (at(*a), at(*b)); + pb.move_to(a.0, a.1); + pb.line_to(b.0, b.1); + } + for (c, r) in &self.circles { + circle(&mut pb, at(*c), *r as f32); + } + if let Some(path) = pb.finish() { + surface.set_fill(None); + surface.set_stroke(Some(Stroke { + paint: registration(1.0).into(), + width: self.line_width as f32, + miter_limit: 4.0, + line_cap: LineCap::Butt, + line_join: LineJoin::Miter, + opacity: NormalizedF32::ONE, + dash: None, + })); + surface.draw_path(&path); + surface.set_stroke(None); + } + for p in &self.patches { + let (x, y) = at((p.rect.x0, p.rect.y0)); + let Some(path) = rect_path(x, y, p.rect.width() as f32, p.rect.height() as f32) else { continue }; + let [c, m, yl, k] = p.cmyk; + surface.set_fill(Some(Fill { paint: cmyk::Color::new_f32(c, m, yl, k).into(), ..Fill::default() })); + surface.draw_path(&path); + } + surface.set_fill(None); + } +} + +fn check(opts: &MarkOptions) -> Result<(), MarksError> { + if !opts.any() { + return Err(MarksError::NoMarks); + } + for (what, value, zero_ok) in [("offset", opts.offset, true), ("length", opts.length, false), ("line width", opts.line_width, false)] { + let low = if zero_ok { value < 0.0 } else { value <= 0.0 }; + if !value.is_finite() || low || value > MAX_MARK_SIZE { + return Err(MarksError::Size { what, value }); + } + } + if opts.line_width > opts.length / 3.0 { + return Err(MarksError::Size { what: "line width", value: opts.line_width }); + } + Ok(()) +} + +/// The bottom band's information line: left-aligned past the crop marks, centred in the band. +fn info_line(trim: Rect, gap: f64, len: f64, clear: f64) -> InfoLine { + let size = (len * 0.4).min(7.0); + let baseline = trim.y1 + gap + len / 2.0 + size * 0.35; + InfoLine { origin: (trim.x0 + len, baseline), size, max_width: (trim.width() / 2.0 - len - clear).max(0.0) } +} + +/// How far a registration target's cross reaches from its centre; the bars and the information +/// line stop short of it. +fn target_arm(len: f64) -> f64 { + len / 3.0 * 1.4 +} + +/// A circle of four Béziers (κ = 0.5523). +fn circle(pb: &mut PathBuilder, (x, y): (f32, f32), r: f32) { + let k = r * 0.552_284_8; + pb.move_to(x + r, y); + pb.cubic_to(x + r, y + k, x + k, y + r, x, y + r); + pb.cubic_to(x - k, y + r, x - r, y + k, x - r, y); + pb.cubic_to(x - r, y - k, x - k, y - r, x, y - r); + pb.cubic_to(x + k, y - r, x + r, y - k, x + r, y); + pb.close(); +} + +fn rect_path(x: f32, y: f32, w: f32, h: f32) -> Option { + let mut pb = PathBuilder::new(); + pb.push_rect(PdfRect::from_xywh(x, y, w, h)?); + pb.finish() +} diff --git a/crates/prepress/tests/marks.rs b/crates/prepress/tests/marks.rs new file mode 100644 index 000000000..be5714e1f --- /dev/null +++ b/crates/prepress/tests/marks.rs @@ -0,0 +1,172 @@ +//! 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"); +}