diff --git a/crates/doc/src/marks.rs b/crates/doc/src/marks.rs index ac1500eb..bcfaf007 100644 --- a/crates/doc/src/marks.rs +++ b/crates/doc/src/marks.rs @@ -1,13 +1,14 @@ //! Printer's marks drawn as art: the trim marks of Object → Create Trim Marks and Effect → Crop -//! Marks, stroked in the Registration colour so that they print on every plate. +//! Marks, stroked in the Registration colour so that they print on every plate, and the printer's +//! marks around a printed page ([`PrinterMarks`]: PDF export's Marks and Bleeds, and print). use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vectorcraft_color::Paint; +use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Point, Rect, shapes}; -use crate::{Appearance, Node, NodeId}; +use crate::{Appearance, CharStyle, Node, NodeId, NodeKind, TextObject}; /// Trim mark style (the preference `japaneseCropMarks` picks Japanese). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -125,6 +126,164 @@ pub fn outset(r: Rect, [top, bottom, left, right]: [f64; 4]) -> Rect { Rect::new(r.x0 - left, r.y0 - top, r.x1 + right, r.y1 + bottom) } +/// The printer's marks around a printed page's trim box (its artboard): trim marks, a +/// registration target at the middle of each side, colour bars along the top and the page +/// information line below the bottom marks. They sit in a band [`PrinterMarks::LENGTH`] deep that +/// starts at the offset from the trim box (outside the bleed), and are drawn in Registration, the +/// colour bars aside. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrinterMarks { + pub trim: bool, + pub registration: bool, + pub color_bars: bool, + pub page_info: bool, + pub style: MarkStyle, + /// Stroke weight of the trim marks and registration targets. + pub weight: f64, + /// Roman: the gap between the trim box and the marks (at least the bleed). + pub offset: f64, +} + +/// Where the printer's marks of one page go ([`PrinterMarks::layout`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MarksLayout { + /// Lines stroked in Registration: the trim marks and the targets' cross hairs. + pub lines: Vec<(Point, Point)>, + /// Registration target rings (centre, radius), stroked in Registration. + pub rings: Vec<(Point, f64)>, + /// The colour bars: each patch and its paint. + pub patches: Vec<(Rect, Paint)>, + /// The page information line's baseline start. + pub info: Option, +} + +impl PrinterMarks { + /// How deep the band of marks is (the trim marks' length). + pub const LENGTH: f64 = 18.0; + /// The side of a colour bar patch. + pub const PATCH: f64 = 10.0; + /// The page information's type size. + pub const INFO_SIZE: f64 = 7.0; + + /// Any mark is on. + pub fn any(&self) -> bool { + self.trim || self.registration || self.color_bars || self.page_info + } + + /// The trim marks for a page with `bleed` (`[top, bottom, left, right]`): Roman ones outside + /// the bleed, Japanese ones with their outer lines on the bleed (3 mm without one). + pub fn trim_marks(&self, bleed: [f64; 4]) -> TrimMarks { + let most = bleed.iter().copied().fold(0.0, f64::max); + let d = TrimMarks::of(self.style); + TrimMarks { offset: self.offset.max(most), length: Self::LENGTH, bleed: if most > 0.0 { most } else { d.bleed }, weight: self.weight, ..d } + } + + /// The distance from the trim box to the band of marks. + fn gap(&self, bleed: [f64; 4]) -> f64 { + let t = self.trim_marks(bleed); + match self.style { + MarkStyle::Roman => t.offset, + MarkStyle::Japanese => t.bleed, + } + } + + /// How far the marks reach outside the trim box, `[top, bottom, left, right]` (0 without + /// marks): the band on every side, and the page information below it. + pub fn reach(&self, bleed: [f64; 4]) -> [f64; 4] { + if !self.any() { + return [0.0; 4]; + } + let band = self.gap(bleed) + Self::LENGTH + self.weight; + let bottom = if self.page_info { band + Self::INFO_SIZE * 1.6 } else { band }; + [band, bottom, band, band] + } + + /// Where the marks around trim box `trim` go, for a page with `bleed` and the spot inks + /// `spots` (their colour bar patches follow the process ones). + pub fn layout(&self, trim: Rect, bleed: [f64; 4], spots: &[Paint]) -> MarksLayout { + let mut out = MarksLayout::default(); + if !self.any() { + return out; + } + let (gap, l) = (self.gap(bleed), Self::LENGTH); + if self.trim { + out.lines = self.trim_marks(bleed).lines(trim); + } + let c = trim.center(); + // The middle of the band. + let mid = gap + l / 2.0; + if self.registration { + let sides = [ + (Point::new(c.x, trim.y0), 0.0, -1.0), + (Point::new(trim.x1, c.y), 1.0, 0.0), + (Point::new(c.x, trim.y1), 0.0, 1.0), + (Point::new(trim.x0, c.y), -1.0, 0.0), + ]; + for (p, dx, dy) in sides { + let at = Point::new(p.x + dx * mid, p.y + dy * mid); + out.rings.push((at, l / 3.0)); + out.rings.push((at, l / 6.0)); + // Japanese trim marks draw this cross already. + if !(self.trim && self.style == MarkStyle::Japanese) { + out.lines.push((Point::new(at.x - l / 2.0, at.y), Point::new(at.x + l / 2.0, at.y))); + out.lines.push((Point::new(at.x, at.y - l / 2.0), Point::new(at.x, at.y + l / 2.0))); + } + } + } + if self.color_bars { + let s = Self::PATCH; + let y = trim.y0 - mid - s / 2.0; + let cmyk = |c, m, y, k| Paint::solid(Color::cmyk(c, m, y, k)); + // Solid inks, their overprints and the spot inks left of the top target; black tints + // right of it. Patches that don't fit are left out. + let solids = [ + cmyk(1.0, 0.0, 0.0, 0.0), + cmyk(0.0, 1.0, 0.0, 0.0), + cmyk(0.0, 0.0, 1.0, 0.0), + cmyk(0.0, 0.0, 0.0, 1.0), + cmyk(1.0, 1.0, 0.0, 0.0), + cmyk(1.0, 0.0, 1.0, 0.0), + cmyk(0.0, 1.0, 1.0, 0.0), + cmyk(1.0, 1.0, 1.0, 0.0), + ]; + let tints: Vec = (1..=10).map(|i| cmyk(0.0, 0.0, 0.0, i as f32 / 10.0)).collect(); + let clear = l / 2.0 + 2.0; + let rows = [(solids.iter().chain(spots).cloned().collect(), trim.x0 + s / 2.0, c.x - clear), (tints, c.x + clear, trim.x1 - s / 2.0)]; + for (paints, from, to) in rows { + let fits = ((to - from) / s).floor().max(0.0) as usize; + for (i, paint) in paints.into_iter().take(fits).enumerate() { + let x = from + i as f64 * s; + out.patches.push((Rect::new(x, y, x + s, y + s), paint)); + } + } + } + if self.page_info { + out.info = Some(Point::new(trim.x0, trim.y1 + gap + l + self.weight + Self::INFO_SIZE * 1.1)); + } + out + } + + /// The marks around `trim` as a group named "Printer's Marks", with ids from `alloc` (the + /// group's last): the colour bar patches, then Registration lines and rings, then `info` as + /// point type in Registration. + pub fn art(&self, trim: Rect, bleed: [f64; 4], spots: &[Paint], info: &str, alloc: &mut dyn FnMut() -> NodeId) -> Node { + let m = self.layout(trim, bleed, spots); + let reg = Appearance::basic(Paint::None, Paint::registration(), self.weight); + let shape = |id, path, ap: &Appearance| Arc::new(Node::path(id, path, ap.clone())); + let mut children: Vec> = + m.patches.into_iter().map(|(r, p)| shape(alloc(), shapes::rectangle(r), &Appearance::basic(p, Paint::None, 0.0))).collect(); + children.extend(m.lines.into_iter().map(|(a, b)| shape(alloc(), shapes::line(a, b), ®))); + children.extend(m.rings.into_iter().map(|(c, r)| shape(alloc(), shapes::ellipse(Rect::from_center_size(c, (2.0 * r, 2.0 * r))), ®))); + if let Some(at) = m.info.filter(|_| !info.is_empty()) { + let style = CharStyle { size: Self::INFO_SIZE, fill: Paint::registration(), ..Default::default() }; + children.push(Arc::new(Node::new(alloc(), NodeKind::Text(Box::new(TextObject::point(at, info, style)))))); + } + let mut g = Node::group(alloc(), children); + g.name = Some("Printer's Marks".into()); + g + } +} + #[cfg(test)] mod tests { use super::*; @@ -152,4 +311,57 @@ mod tests { assert!(g.children().unwrap()[0].appearance.stroke().unwrap().paint.is_registration()); assert_eq!(MarkStyle::parse("Japanese"), Some(MarkStyle::Japanese)); } + + fn all(style: MarkStyle) -> PrinterMarks { + PrinterMarks { trim: true, registration: true, color_bars: true, page_info: true, style, weight: 0.25, offset: 6.0 } + } + + #[test] + fn printer_marks_sit_in_a_band_outside_the_trim_box() { + let trim = Rect::new(0.0, 0.0, 200.0, 100.0); + let m = all(MarkStyle::Roman); + let lay = m.layout(trim, [0.0; 4], &[]); + // Golden positions: the band starts at the 6 pt offset and is 18 pt deep. + assert_eq!(lay.lines[0], (Point::new(-6.0, 0.0), Point::new(-24.0, 0.0)), "top-left trim mark"); + assert_eq!(lay.rings[..2], [(Point::new(100.0, -15.0), 6.0), (Point::new(100.0, -15.0), 3.0)], "top target"); + assert_eq!(lay.lines[8..10], [(Point::new(91.0, -15.0), Point::new(109.0, -15.0)), (Point::new(100.0, -24.0), Point::new(100.0, -6.0))]); + assert_eq!(lay.patches[0], (Rect::new(5.0, -20.0, 15.0, -10.0), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))), "cyan first"); + assert_eq!(lay.patches[8].0, Rect::new(111.0, -20.0, 121.0, -10.0), "black tints right of the target"); + assert_eq!(lay.patches.len(), 8 + 8, "as many patches as fit each half"); + assert_eq!(lay.info, Some(Point::new(0.0, 100.0 + 24.25 + 7.7))); + let reach = m.reach([0.0; 4]); + assert_eq!(reach, [24.25, 24.25 + 11.2, 24.25, 24.25]); + // Everything lies outside the trim box and inside the sheet the reach makes. + let sheet = outset(trim, reach); + let points = lay.lines.iter().flat_map(|(a, b)| [*a, *b]).chain(lay.rings.iter().map(|(c, _)| *c)); + for p in points.chain(lay.patches.iter().flat_map(|(r, _)| [r.origin(), Point::new(r.x1, r.y1)])) { + assert!(sheet.contains(p) && !trim.inflate(-1e-9, -1e-9).contains(p), "{p:?}"); + } + // A bleed pushes the marks out; no marks reach nothing. + assert_eq!(m.layout(trim, [9.0, 0.0, 0.0, 0.0], &[]).lines[0], (Point::new(-9.0, 0.0), Point::new(-27.0, 0.0))); + assert_eq!(PrinterMarks { trim: false, registration: false, color_bars: false, page_info: false, ..m }.reach([9.0; 4]), [0.0; 4]); + } + + #[test] + fn japanese_printer_marks_share_the_centre_crosses_and_draw_as_art() { + let trim = Rect::new(0.0, 0.0, 200.0, 100.0); + let m = all(MarkStyle::Japanese); + let lay = m.layout(trim, [0.0; 4], &[]); + assert_eq!(lay.lines.len(), 24, "the targets add rings to the trim marks' centre crosses"); + let b = TrimMarks::default().bleed; + assert_eq!(lay.rings[0].0, Point::new(100.0, -(b + 9.0))); + let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 }; + let wide = Rect::new(0.0, 0.0, 400.0, 100.0); + assert_eq!(m.layout(wide, [0.0; 4], std::slice::from_ref(&spot)).patches[8].1, spot, "spot inks follow the process patches"); + let mut n = 0; + let g = m.art(trim, [0.0; 4], &[], "Page 1", &mut || { + n += 1; + NodeId(n) + }); + let kids = g.children().unwrap(); + let NodeKind::Text(t) = &kids.last().unwrap().kind else { panic!("the page information is type") }; + assert_eq!((t.plain_text().as_str(), t.first_style().fill.is_registration()), ("Page 1", true)); + let strokes: Vec<_> = kids.iter().filter_map(|k| k.appearance.stroke()).filter(|s| !s.paint.is_none()).collect(); + assert!(strokes.len() == 24 + 8 && strokes.iter().all(|s| s.paint.is_registration()), "lines and rings in Registration"); + } } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index b0814790..83e6177d 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = MediaBox = artboard + bleed, output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 9fe8e9dc..3d94ea52 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -104,9 +104,9 @@ fn pdf_a_refuses_pdf_2() { #[test] fn options_not_applied_yet_and_document_features_warn() { let mut s = session(1); - let w = warnings(&export(&mut s, json!({"thumbnails": true, "marks": {"trim": true}}))); + let w = warnings(&export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}}))); assert_eq!(w.len(), 2, "{w:?}"); - assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("marks"))); + assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("fast web view")), "printer's marks are drawn: no warning"); // A pattern stroke is written as its tiles clipped to the stroke: nothing to report. let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [tile]})).unwrap(); diff --git a/crates/engine/src/tests_pdfmarks.rs b/crates/engine/src/tests_pdfmarks.rs index 2fb23159..a9266b78 100644 --- a/crates/engine/src/tests_pdfmarks.rs +++ b/crates/engine/src/tests_pdfmarks.rs @@ -1,5 +1,5 @@ //! `document.exportPdf` Marks and Bleeds: the document's bleed or custom values set the page -//! boxes, non-printing layers are left out unless asked. +//! boxes, printer's marks print in Registration, non-printing layers are left out unless asked. use serde_json::{Value, json}; use vectorcraft_pdf::CropTo; @@ -40,6 +40,20 @@ fn the_document_bleed_or_custom_values_grow_each_page() { assert_eq!(boxes(&export(&mut s, json!({"bleed": {"left": 4.5}})).0).0.width(), 104.5); } +#[test] +fn printer_marks_print_in_registration_with_no_warning() { + let mut s = session(); + let marks = json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true, "kind": "japanese"}); + let (bytes, warnings) = export(&mut s, json!({"marks": marks, "bleed": {"useDocument": true}})); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(String::from_utf8_lossy(&bytes).contains("/Separation/All")); + let (media, bleed, _) = boxes(&bytes); + // Japanese marks start at the 9 pt bleed and reach 18 pt (+ their weight) beyond it. + assert_eq!((bleed.width(), media.width()), (118.0, 100.0 + 2.0 * (9.0 + 18.0 + 0.25)), "the sheet holds the marks: {media:?}"); + let v = s.execute("document.pdfSettings", &json!({"marks": marks, "bleed": {"top": 9}, "includeDocument": true})).unwrap(); + assert_eq!(v["warnings"], json!([])); +} + #[test] fn non_printing_layers_are_left_out_unless_included() { let mut s = session(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 394cef20..323cef3a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -68,7 +68,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result Result Result Result