diff --git a/crates/doc/src/marks.rs b/crates/doc/src/marks.rs index 7a7a2104e..bcfaf0073 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)] @@ -120,6 +121,169 @@ impl TrimMarks { } } +/// `r` grown by `[top, bottom, left, right]` (a bleed, or how far marks reach). +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::*; @@ -147,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/doc/src/setup.rs b/crates/doc/src/setup.rs index ee92f7456..5a8934383 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -257,8 +257,7 @@ impl Default for DocSetup { impl DocSetup { /// `artboard` grown by the bleed. pub fn bleed_rect(&self, artboard: Rect) -> Rect { - let [top, bottom, left, right] = self.bleed; - Rect::new(artboard.x0 - left, artboard.y0 - top, artboard.x1 + right, artboard.y1 + bottom) + crate::marks::outset(artboard, self.bleed) } pub fn has_bleed(&self) -> bool { self.bleed.iter().any(|b| *b > 0.0) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index abd5fcc63..83e6177d5 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -40,10 +40,21 @@ pub(super) const OPTIONS: &[FormatOption] = &[ }, FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" }, FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" }, - FormatOption { name: "bleed", ty: "object", default: "null", description: "{useDocument, top, bottom, left, right} in points" }, + FormatOption { + name: "bleed", + ty: "object", + default: "null", + description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox)", + }, FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" }, FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" }, FormatOption { name: "security", ty: "object", default: "null", description: "passwords (not supported yet) and permissions" }, + FormatOption { + name: "includeNonPrinting", + ty: "boolean", + default: "false", + description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)", + }, ]; pub fn specs() -> Vec { @@ -53,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?, 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, top, bottom, left, right} (pt), 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 9fe8e9dce..3d94ea529 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/lib.rs b/crates/engine/src/lib.rs index 291d37cc0..12247d64b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1094,6 +1094,8 @@ mod tests_pattern; #[cfg(test)] mod tests_pdffidelity; #[cfg(test)] +mod tests_pdfmarks; +#[cfg(test)] mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; diff --git a/crates/engine/src/tests_pdfmarks.rs b/crates/engine/src/tests_pdfmarks.rs new file mode 100644 index 000000000..a9266b788 --- /dev/null +++ b/crates/engine/src/tests_pdfmarks.rs @@ -0,0 +1,73 @@ +//! `document.exportPdf` Marks and Bleeds: the document's bleed or custom values set the page +//! boxes, printer's marks print in Registration, non-printing layers are left out unless asked. + +use serde_json::{Value, json}; +use vectorcraft_pdf::CropTo; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "bleed": 9})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s +} + +/// The PDF `document.exportPdf {p}` writes, and its warnings. +fn export(s: &mut Session, p: Value) -> (Vec, Vec) { + let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (bytes, serde_json::from_value(v["warnings"].clone()).unwrap()) +} + +fn boxes(bytes: &[u8]) -> (kurbo::Rect, kurbo::Rect, kurbo::Rect) { + let page = &vectorcraft_pdf::info(bytes, None).unwrap().pages[0]; + let get = |c: CropTo| page.boxes.iter().find(|(b, _)| *b == c).unwrap().1; + (get(CropTo::Media), get(CropTo::Bleed), get(CropTo::Trim)) +} + +#[test] +fn the_document_bleed_or_custom_values_grow_each_page() { + let mut s = session(); + let (bytes, warnings) = export(&mut s, json!({"bleed": {"useDocument": true}})); + assert!(warnings.is_empty(), "{warnings:?}"); + let (media, bleed, trim) = boxes(&bytes); + assert_eq!((media.width(), media.height()), (118.0, 98.0), "MediaBox = artboard + the document's 9 pt bleed"); + assert_eq!((bleed, trim), (media, kurbo::Rect::new(9.0, 9.0, 109.0, 89.0))); + // Press Quality uses the document's bleed; the default preset and custom values don't. + assert_eq!(boxes(&export(&mut s, json!({"preset": "Press Quality"})).0).0.width(), 118.0); + assert_eq!(boxes(&export(&mut s, json!({})).0).0.width(), 100.0); + 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(); + let layer = s.execute("layer.new", &json!({"name": "Notes"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setProps", &json!({"id": layer, "printable": false})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); + let paths = |s: &mut Session, p| { + let doc = vectorcraft_pdf::import(&export(s, p).0).unwrap(); + let mut n = 0; + doc.walk(|x| n += usize::from(x.path_data().is_some())); + n + }; + let without = paths(&mut s, json!({})); + assert_eq!(paths(&mut s, json!({"includeNonPrinting": true})), without + 1, "the ellipse on the non-printing layer"); + s.execute("layer.setProps", &json!({"id": layer, "printable": true})).unwrap(); + assert_eq!(paths(&mut s, json!({})), without + 1); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 8f1b4e040..323cef3a8 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -19,6 +19,7 @@ use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use crate::lab_spot::{find, rfind}; +use crate::marks::PageBoxes; use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard}; /// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). @@ -67,7 +68,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result Result Result Result { interpolate: bool, /// How images are resampled and compressed. compression: &'a crate::CompressionSettings, + /// Layers whose Print option is off are written too. + non_printing: bool, } /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). @@ -600,7 +624,9 @@ impl Exporter<'_> { if !force && !n.visible { return; } - if let NodeKind::Layer { template: true, .. } = n.kind { + if let NodeKind::Layer { template, printable, .. } = n.kind + && (template || !(printable || self.non_printing)) + { return; } match n.visual_bounds() { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 362372494..a5fe58432 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -6,7 +6,10 @@ //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and //! freeform gradients as images (clipped to what they paint), embedded images (resampled and -//! compressed as the Compression settings say) and text as outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF +//! compressed as the Compression settings say) and text as outlined glyph paths. Hidden objects, +//! guides and template layers are skipped, and so are non-printing layers unless asked for. +//! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the +//! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve //! Editing the native document rides along as an embedded file ([`editing()`]). @@ -35,6 +38,7 @@ mod import_scan; mod import_shading; mod import_text; mod lab_spot; +mod marks; mod pages; mod presets; mod settings; @@ -197,6 +201,8 @@ mod tests_import_layers; #[cfg(test)] mod tests_import_options; #[cfg(test)] +mod tests_marks; +#[cfg(test)] mod tests_presets; #[cfg(test)] mod tests_settings; diff --git a/crates/pdf/src/marks.rs b/crates/pdf/src/marks.rs new file mode 100644 index 000000000..ef71d44da --- /dev/null +++ b/crates/pdf/src/marks.rs @@ -0,0 +1,88 @@ +//! Marks and Bleeds: the boxes of each exported page and its printer's marks. The marks' geometry +//! is the shared [`PrinterMarks`] (print draws the same marks); here it gets the page's bleed, +//! spot inks and page information. + +use vectorcraft_color::Paint; +use vectorcraft_doc::marks::{MarkStyle, PrinterMarks, outset}; +use vectorcraft_doc::setup::MAX_BLEED; +use vectorcraft_doc::{Document, Node, NodeId}; +use vectorcraft_geom::Rect; + +use crate::{MarkKind, MarkSettings, PdfSettings}; + +impl MarkSettings { + /// These settings as the shared mark geometry. + pub fn printer_marks(&self) -> PrinterMarks { + PrinterMarks { + trim: self.trim, + registration: self.registration, + color_bars: self.color_bars, + page_info: self.page_info, + style: match self.kind { + MarkKind::Roman => MarkStyle::Roman, + MarkKind::Japanese => MarkStyle::Japanese, + }, + weight: self.weight, + offset: self.offset, + } + } +} + +impl PdfSettings { + /// The bleed of `doc`'s pages, `[top, bottom, left, right]` in points: the document's (Document + /// Setup; one read from a file is kept to 0–[`MAX_BLEED`]) with Use Document Bleed, else the + /// settings' own. + pub fn bleed_of(&self, doc: &Document) -> [f64; 4] { + if self.bleed.use_document { doc.setup.bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 }) } else { self.bleed.values() } + } +} + +/// The boxes of one page, in document coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PageBoxes { + /// The artboard: the finished page. + pub trim: Rect, + /// The trim box grown by the bleed: the art it holds is kept. + pub bleed: Rect, + /// The sheet: the bleed box, grown to hold the printer's marks. + pub media: Rect, +} + +impl PageBoxes { + pub fn new(trim: Rect, bleed: [f64; 4], marks: &PrinterMarks) -> Self { + let reach = marks.reach(bleed); + let most: [f64; 4] = std::array::from_fn(|i| bleed[i].max(reach[i])); + Self { trim, bleed: outset(trim, bleed), media: outset(trim, most) } + } +} + +/// The page information printed under the marks: the file's title, the artboard (its name and +/// number) and the date and time of the export (UTC). +pub(crate) fn page_info(doc: &Document, title: &str, artboard: usize, created: Option) -> String { + let title = if title.trim().is_empty() { "Untitled" } else { title.trim() }; + let name = doc.artboards.get(artboard).map_or("", |a| a.name.as_str()); + let mut info = format!("{title} · {name} ({} of {})", artboard + 1, doc.artboards.len()); + if let Some(t) = created { + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); + info.push_str(&format!(" · {y:04}-{mo:02}-{d:02} {h:02}:{mi:02} UTC")); + } + info +} + +/// The marks of a page as art (`None` without marks): its spot inks get colour bar patches. +pub(crate) fn art(doc: &Document, marks: &PrinterMarks, boxes: &PageBoxes, bleed: [f64; 4], info: &str) -> Option { + if !marks.any() { + return None; + } + let spots: Vec = doc + .spot_names() + .into_iter() + .filter_map(|name| doc.global_color(&name).map(|color| Paint::Solid { color, swatch: Some(name), tint: 1.0 })) + .collect(); + // The marks are drawn straight from this art, so their ids don't matter. + let mut next = 0; + Some(marks.art(boxes.trim, bleed, &spots, info, &mut || { + next += 1; + NodeId(next) + })) +} diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index b472646b9..7d64079c1 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -209,6 +209,9 @@ pub struct PdfSettings { pub view_after_saving: bool, /// Write top-level layers as PDF layers (optional content). pub create_layers: bool, + /// Keep the layers whose Print option is off (they are left out otherwise, unless + /// `create_layers` is on). + pub include_non_printing: bool, pub compression: CompressionSettings, pub marks: MarkSettings, pub bleed: BleedSettings, @@ -310,7 +313,8 @@ pub struct BleedSettings { } impl BleedSettings { - fn values(&self) -> [f64; 4] { + /// `[top, bottom, left, right]`. + pub(crate) fn values(&self) -> [f64; 4] { [self.top, self.bottom, self.left, self.right] } } @@ -439,8 +443,6 @@ impl PdfSettings { (self.thumbnails, "page thumbnails are not embedded yet"), (self.fast_web_view, "fast web view (a linearised file) is not written yet"), (self.create_layers, "PDF layers are not written yet: every layer is plain page content"), - (self.marks.any(), "printer's marks are not drawn yet"), - (!self.bleed.use_document && self.bleed.values().iter().any(|v| *v > 0.0), "bleed is not added yet: each page is its artboard"), (o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"), (o.profiles != ProfileInclusion::None, "ICC profiles are not embedded yet"), ( diff --git a/crates/pdf/src/tests_marks.rs b/crates/pdf/src/tests_marks.rs new file mode 100644 index 000000000..6f807e6fc --- /dev/null +++ b/crates/pdf/src/tests_marks.rs @@ -0,0 +1,171 @@ +//! Marks and Bleeds: page boxes, the art kept in the bleed, non-printing layers and printer's marks. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_doc::marks::PrinterMarks; +use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +/// A 200 × 100 document with a red square reaching 5 pt left of its artboard. +fn doc() -> Document { + let mut d = Document::new(200.0, 100.0); + let layer = d.default_layer().unwrap(); + let id = d.alloc_id(); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(Rect::new(-5.0, 10.0, 40.0, 50.0)), red)).unwrap(); + d +} + +fn settings(v: serde_json::Value) -> PdfSettings { + serde_json::from_value(v).unwrap() +} + +/// The PDF of `d` with settings `v`; marks and bleed raise no warnings. +fn pdf(d: &Document, v: serde_json::Value) -> Vec { + let opts = PdfOptions { settings: settings(v), created: Some(1_791_200_000), ..PdfOptions::uncompressed() }; + let r = export_with_report(d, &opts).unwrap(); + assert!(r.warnings.iter().all(|w| !w.contains("bleed") && !w.contains("marks")), "{:?}", r.warnings); + r.bytes +} + +/// Page 1's box `which` (PDF space: y up). +fn page_box(bytes: &[u8], which: CropTo) -> Rect { + info(bytes, None).unwrap().pages[0].boxes.iter().find(|(c, _)| *c == which).unwrap().1 +} + +/// The bounds of every path of `d` painted with a paint `pick` accepts. +fn painted(d: &Document, pick: impl Fn(&Paint) -> bool) -> Vec { + let mut out = vec![]; + d.walk(|n| { + let paints = n.appearance.fill().map(|f| &f.paint).into_iter().chain(n.appearance.stroke().map(|s| &s.paint)); + if paints.into_iter().any(&pick) + && let Some(b) = n.path_data().and_then(|p| p.bounds()) + { + out.push(b); + } + }); + out +} + +fn rgb(p: &Paint, rgb: [u8; 3]) -> bool { + p.color().is_some_and(|c| c.to_rgba8(1.0)[..3] == rgb) +} + +#[test] +fn bleed_grows_the_media_box_around_the_trim_box() { + let d = doc(); + // No bleed and no marks: the page is the artboard. + let plain = pdf(&d, json!({})); + assert_eq!(page_box(&plain, CropTo::Media), Rect::new(0.0, 0.0, 200.0, 100.0)); + assert_eq!(page_box(&plain, CropTo::Trim), Rect::new(0.0, 0.0, 200.0, 100.0)); + // A 9 pt bleed, custom or the document's. + let mut nine = d.clone(); + nine.setup.bleed = [9.0; 4]; + for v in [json!({"bleed": {"top": 9, "bottom": 9, "left": 9, "right": 9}}), json!({"bleed": {"useDocument": true, "top": 1}})] { + let bytes = pdf(&nine, v.clone()); + let media = page_box(&bytes, CropTo::Media); + assert_eq!((media.width(), media.height()), (218.0, 118.0), "{v}: MediaBox = artboard + 2 × 9 pt"); + assert_eq!(page_box(&bytes, CropTo::Trim), Rect::new(9.0, 9.0, 209.0, 109.0), "{v}: TrimBox = artboard"); + assert_eq!(page_box(&bytes, CropTo::Bleed), media, "{v}: BleedBox = artboard + bleed"); + } + // A document bleed read from a file is kept in range. + nine.setup.bleed = [f64::NAN, -3.0, 1e9, 2.0]; + let b = pdf(&nine, json!({"bleed": {"useDocument": true}})); + assert_eq!(page_box(&b, CropTo::Media), Rect::new(0.0, 0.0, 274.0, 100.0)); + // Uneven bleed (PDF space is y up: the bottom bleed is below the trim box). + let bytes = pdf(&d, json!({"bleed": {"top": 4, "bottom": 2, "left": 3, "right": 1}})); + assert_eq!(page_box(&bytes, CropTo::Media), Rect::new(0.0, 0.0, 204.0, 106.0)); + assert_eq!(page_box(&bytes, CropTo::Trim), Rect::new(3.0, 2.0, 203.0, 102.0)); +} + +#[test] +fn art_in_the_bleed_is_kept_and_clipped_to_the_bleed_box() { + let d = doc(); + let back = import(&pdf(&d, json!({"bleed": {"left": 9, "top": 9, "bottom": 9, "right": 9}}))).unwrap(); + let red = painted(&back, |p| rgb(p, [255, 0, 0])); + assert_eq!(red.len(), 1); + assert!((red[0].x0 - 4.0).abs() < 0.01, "the square reaches 5 pt into the 9 pt bleed: {red:?}"); + // With marks the sheet is larger than the bleed box: the art is clipped to it. + let bytes = pdf(&d, json!({"bleed": {"left": 3}, "marks": {"trim": true}})); + let (trim, bleed) = (page_box(&bytes, CropTo::Trim), page_box(&bytes, CropTo::Bleed)); + assert!((trim.x0 - bleed.x0 - 3.0).abs() < 1e-3 && bleed.x0 > 0.0, "{trim:?} {bleed:?}"); + let back = import(&bytes).unwrap(); + let mut clips = vec![]; + back.walk(|n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + clips.extend(children.first().and_then(|c| c.path_data()).and_then(|p| p.bounds())); + } + }); + let media = page_box(&bytes, CropTo::Media); + let bleed_doc = Rect::new(bleed.x0, media.y1 - bleed.y1, bleed.x1, media.y1 - bleed.y0); + assert!(clips.iter().any(|c| (c.x0 - bleed_doc.x0).abs() < 0.01 && (c.x1 - bleed_doc.x1).abs() < 0.01), "{clips:?} vs {bleed_doc:?}"); +} + +#[test] +fn non_printing_layers_are_left_out_unless_asked_for() { + let mut d = doc(); + let mut layer = Node::layer(d.alloc_id(), "Notes", LayerColor::Preset(1)); + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let note = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 20.0, 150.0, 60.0)), blue); + if let NodeKind::Layer { children, printable, .. } = &mut layer.kind { + *printable = false; + children.push(Arc::new(note)); + } + d.layers.push(Arc::new(layer)); + let blues = |v| painted(&import(&pdf(&d, v)).unwrap(), |p| rgb(p, [0, 0, 255])).len(); + assert_eq!(blues(json!({})), 0, "a non-printing layer is absent"); + assert_eq!(blues(json!({"includeNonPrinting": true})), 1); + assert_eq!(blues(json!({"createLayers": true})), 1, "PDF layers keep every layer"); +} + +#[test] +fn marks_are_drawn_in_registration_on_every_plate_inside_the_media_box() { + // Wide enough for the spot ink's colour bar patch after the process ones. + let mut d = doc(); + let w = 300.0; + d.artboards[0].rect = Rect::new(0.0, 0.0, w, 100.0); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + let all = json!({"marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}); + let bytes = pdf(&d, all.clone()); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Separation/All"), "Registration is the /All separation"); + assert!(text.contains("/Separation/Gold"), "the spot ink gets a colour bar patch"); + // MediaBox fits the marks: [top, bottom, left, right] around the trim box. + let [top, bottom, left, right] = settings(all).marks.printer_marks().reach([0.0; 4]); + let (media, trim) = (page_box(&bytes, CropTo::Media), page_box(&bytes, CropTo::Trim)); + let near = |a: f64, b: f64| (a - b).abs() < 1e-3; + assert!(near(media.width(), w + left + right) && near(media.height(), 100.0 + top + bottom), "{media:?}"); + assert!(near(trim.x0, left) && near(trim.y0, bottom), "{trim:?}"); + // Read back (y down): every Registration mark lies outside the trim box and inside the sheet. + let back = import(&bytes).unwrap(); + let sheet = Rect::new(0.0, 0.0, media.width(), media.height()).inflate(0.5, 0.5); + let trim_doc = Rect::new(left, top, left + w, top + 100.0).inflate(-0.5, -0.5); + let marks = painted(&back, Paint::is_registration); + // 8 trim marks, 4 targets (2 rings and 2 lines each) and the page information's glyphs. + assert!(marks.len() > 8 + 16, "{}", marks.len()); + for b in &marks { + assert!(sheet.contains_rect(*b) && b.intersect(trim_doc).area() <= 0.0, "{b:?}"); + } + let gold = painted(&back, |p| matches!(p, Paint::Solid { swatch: Some(s), .. } if s == "Gold")); + assert_eq!(gold.len(), 1); + assert!(gold[0].y1 < trim_doc.y0, "colour bars sit above the trim box: {gold:?}"); +} + +#[test] +fn page_information_names_the_file_artboard_and_date() { + let d = doc(); + assert_eq!(crate::marks::page_info(&d, "Poster", 0, Some(1_791_200_000)), "Poster · Artboard 1 (1 of 1) · 2026-10-05 11:33 UTC"); + assert_eq!(crate::marks::page_info(&d, " ", 0, None), "Untitled · Artboard 1 (1 of 1)"); + // Its glyphs are filled in Registration under the band of bottom marks. + let bytes = pdf(&d, json!({"marks": {"pageInfo": true}})); + let [top, ..] = settings(json!({"marks": {"pageInfo": true}})).marks.printer_marks().reach([0.0; 4]); + let back = import(&bytes).unwrap(); + let glyphs = painted(&back, Paint::is_registration); + assert!(!glyphs.is_empty()); + let band_end = top + 100.0 + 6.0 + PrinterMarks::LENGTH; + assert!(glyphs.iter().all(|g| g.y0 > band_end && g.x0 >= top - 0.5), "{glyphs:?}"); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index cf4deca6c..bc60dc72c 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -74,8 +74,6 @@ fn options_not_applied_yet_come_back_as_warnings() { (json!({"thumbnails": true}), "thumbnails"), (json!({"fastWebView": true}), "fast web view"), (json!({"createLayers": true}), "layers"), - (json!({"marks": {"registration": true}}), "marks"), - (json!({"bleed": {"top": 9}}), "bleed"), (json!({"output": {"conversion": "destination"}}), "conversion"), (json!({"output": {"profiles": "all"}}), "ICC"), (json!({"output": {"trapped": true}}), "trapped"), @@ -85,10 +83,12 @@ fn options_not_applied_yet_come_back_as_warnings() { let w = settings(v.clone()).warnings(); assert!(w.len() == 1 && w[0].contains(word), "{v}: {w:?}"); } - // A document bleed, the default view/overprint choices and applied options (image compression + // Bleed and marks, the default view/overprint choices and applied options (image compression // too: a codec the writer lacks is reported when an image needs it) warn about nothing. for v in [ json!({"bleed": {"useDocument": true, "top": 9}}), + json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}), + json!({"includeNonPrinting": true}), json!({"viewAfterSaving": true}), json!({"compression": {"compressText": false}}), json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}), @@ -98,6 +98,6 @@ fn options_not_applied_yet_come_back_as_warnings() { let r = export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() }) .unwrap(); - assert_eq!(r.warnings.len(), 2, "{:?}", r.warnings); + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); assert!(r.bytes.starts_with(b"%PDF-1.7")); } diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index b052daac7..b5bee3ec8 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -409,6 +409,7 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { flag(ui, d, "fastWebView", "Optimize for fast web view", true); flag(ui, d, "viewAfterSaving", "View PDF after saving", true); flag(ui, d, "createLayers", "Create PDF layers from top-level layers", true); + flag(ui, d, "includeNonPrinting", "Include non-printing layers", true); if editor { heading(ui, "Description"); if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) { @@ -501,12 +502,18 @@ fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { row(ui, "Offset:", |ui| length(ui, d, "marks.offset", unit, true)); heading(ui, "Bleeds"); flag(ui, d, "bleed.useDocument", "Use document bleed settings", true); - let custom = get(d, "bleed.useDocument").as_bool() != Some(true); + // With the document's bleed the fields show it (Document Setup's `[top, bottom, left, right]`). + let document = (get(d, "bleed.useDocument").as_bool() == Some(true)).then(|| app.session.active().map_or([0.0; 4], |s| s.doc.setup.bleed)); egui::Grid::new("pdf-bleed").num_columns(4).spacing([10.0, 6.0]).show(ui, |ui| { - for pair in [[("bleed.top", "Top:"), ("bleed.bottom", "Bottom:")], [("bleed.left", "Left:"), ("bleed.right", "Right:")]] { - for (p, label) in pair { + for pair in [[(0, "bleed.top", "Top:"), (1, "bleed.bottom", "Bottom:")], [(2, "bleed.left", "Left:"), (3, "bleed.right", "Right:")]] { + for (i, p, label) in pair { ui.label(label); - length(ui, d, p, unit, custom); + match document { + Some(b) => { + ui.add_enabled_ui(false, |ui| widgets::num_field(ui, p, b.get(i).copied(), unit, 80.0)); + } + None => length(ui, d, p, unit, true), + } } ui.end_row(); } @@ -750,9 +757,9 @@ mod tests { fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { let (mut app, written, opened) = app(); app.run("ui.savePdfDialog", json!({"path": "/tmp/w.pdf"})).unwrap(); - set_field(&mut app, "marks.trim", json!(true)); + set_field(&mut app, "thumbnails", json!(true)); super::super::confirm(&mut app).unwrap(); - assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("marks"), "{}", app.ui.status); + assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("thumbnails"), "{}", app.ui.status); assert!(opened.borrow().is_empty(), "not opened unless asked"); app.run("ui.savePdfDialog", json!({"path": "/tmp/bad.pdf"})).unwrap(); set_field(&mut app, "__allArtboards", json!(false)); @@ -796,6 +803,35 @@ mod tests { assert!(app.run("ui.savePdfDialog", json!({"compatibility": "1.0"})).is_err(), "bad options are refused"); } + #[test] + fn marks_and_bleeds_reach_the_pdf() { + let (mut app, written, _) = app(); + app.run("document.setup", json!({"bleed": 9})).unwrap(); + let notes = app.run("layer.new", json!({})).unwrap()["id"].clone(); + app.run("layer.setProps", json!({"id": notes, "printable": false})).unwrap(); + app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/marks.pdf"})).unwrap(); + set_field(&mut app, "__section", json!("Marks and Bleeds")); + set_field(&mut app, "bleed.useDocument", json!(true)); + set_field(&mut app, "marks.trim", json!(true)); + frame(&mut app); + set_field(&mut app, "__section", json!("General")); + set_field(&mut app, "includeNonPrinting", json!(true)); + frame(&mut app); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["warnings"], json!([]), "marks and bleed are applied"); + let bytes = &written.borrow()[0].1; + let page = &vectorcraft_pdf::info(bytes, None).unwrap().pages[0]; + let trim = page.boxes.iter().find(|(b, _)| *b == vectorcraft_pdf::CropTo::Trim).unwrap().1; + assert_eq!((trim.width(), trim.x0), (120.0, 9.0 + 18.0 + 0.25), "the trim box is the artboard, inside the bleed and marks"); + let mut ellipse = false; + vectorcraft_pdf::import(bytes).unwrap().walk(|n| { + let b = n.path_data().and_then(|p| p.bounds()).unwrap_or_default(); + ellipse |= (b.width() - 20.0).abs() < 0.1 && (b.height() - 20.0).abs() < 0.1; + }); + assert!(ellipse, "the non-printing layer is included"); + } + #[test] fn option_labels_read_well() { assert_eq!(option_label("compression.color.abovePpi"), "Compression › Color › Above Ppi"); diff --git a/docs/mcp.md b/docs/mcp.md index 50781e200..854c751f8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -778,6 +778,19 @@ the preference `japaneseCropMarks` is on or `style: "japanese"` is given. {"name":"run_command","arguments":{"command":"object.createTrimMarks","params":{}}} ``` +PDF export draws printer's marks and bleed itself (the Save PDF dialog's Marks and Bleeds section): +`document.exportPdf {marks: {trim, registration, colorBars, pageInfo, kind, weight, offset}, bleed: {useDocument, top, +bottom, left, right}}`. Each page's TrimBox is its artboard, its BleedBox the artboard grown by the bleed (the +document's with `useDocument`, set by `document.setup {bleed}`; the art there is kept) and its MediaBox the bleed box +grown to hold the marks (the art is clipped to the bleed box). Trim marks, registration targets and the page +information (title, artboard, export date in UTC) are drawn in Registration (`/Separation /All`) outside the bleed; +the colour bars are process, spot and black-tint patches. Layers whose Print option is off (`layer.setProps +{printable: false}`) are left out unless `includeNonPrinting` (or `createLayers`) is on. + +```json +{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","preset":"Press Quality","marks":{"trim":true,"registration":true,"colorBars":true,"pageInfo":true}}}} +``` + ## Scale Strokes & Effects `object.scale`, `object.transform` and `object.transformEach` take `strokes?: bool` (Scale Strokes & Effects) and