From 8fb1733f05ca79d9ffe770c70901fc8cc226fe9a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:37:51 +0700 Subject: [PATCH 1/2] M4.71: PDF pages carry the bleed and trim boxes; non-printing layers are left out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document.exportPdf applies the bleed (the document's with bleed.useDocument, else the custom values): each page's TrimBox is its artboard and its BleedBox and MediaBox the artboard grown by the bleed, so art in the bleed is kept. Layers whose Print option is off are left out unless includeNonPrinting (new setting, Save PDF › General) or createLayers is on. The bleed warning is gone; the Marks and Bleeds section shows the document's bleed when it is used. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/marks.rs | 5 ++ crates/doc/src/setup.rs | 3 +- crates/engine/src/cmd/fileio/pdf.rs | 15 +++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_pdfmarks.rs | 59 ++++++++++++++ crates/pdf/src/export.rs | 23 ++++-- crates/pdf/src/lib.rs | 8 +- crates/pdf/src/marks.rs | 35 ++++++++ crates/pdf/src/settings.rs | 7 +- crates/pdf/src/tests_marks.rs | 108 +++++++++++++++++++++++++ crates/pdf/src/tests_settings.rs | 5 +- crates/ui-egui/src/dialogs/save_pdf.rs | 43 +++++++++- 12 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/tests_pdfmarks.rs create mode 100644 crates/pdf/src/marks.rs create mode 100644 crates/pdf/src/tests_marks.rs diff --git a/crates/doc/src/marks.rs b/crates/doc/src/marks.rs index 7a7a2104e..ac1500eb4 100644 --- a/crates/doc/src/marks.rs +++ b/crates/doc/src/marks.rs @@ -120,6 +120,11 @@ 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) +} + #[cfg(test)] mod tests { use super::*; 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..b08147902 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, 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", has_doc, export_pdf ), 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..2fb23159a --- /dev/null +++ b/crates/engine/src/tests_pdfmarks.rs @@ -0,0 +1,59 @@ +//! `document.exportPdf` Marks and Bleeds: the document's bleed or custom values set the page +//! boxes, 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 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..394cef204 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`). @@ -104,6 +105,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> 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 +607,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..93cd675f1 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 media box). +//! [`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..b3222ffee --- /dev/null +++ b/crates/pdf/src/marks.rs @@ -0,0 +1,35 @@ +//! Marks and Bleeds: the boxes of each exported page. + +use vectorcraft_doc::Document; +use vectorcraft_doc::marks::outset; +use vectorcraft_doc::setup::MAX_BLEED; +use vectorcraft_geom::Rect; + +use crate::PdfSettings; + +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. + pub media: Rect, +} + +impl PageBoxes { + pub fn new(trim: Rect, bleed: [f64; 4]) -> Self { + let r = outset(trim, bleed); + Self { trim, bleed: r, media: r } + } +} diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index b472646b9..ef50721af 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] } } @@ -440,7 +444,6 @@ impl PdfSettings { (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..0ebcd5246 --- /dev/null +++ b/crates/pdf/src/tests_marks.rs @@ -0,0 +1,108 @@ +//! Marks and Bleeds: page boxes, the art kept in the bleed and non-printing layers. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +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`; the bleed raises no warning. +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")), "{:?}", 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() { + 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:?}"); +} + +#[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"); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index cf4deca6c..0cc1ba408 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -75,7 +75,6 @@ fn options_not_applied_yet_come_back_as_warnings() { (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 +84,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, 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}}), + json!({"includeNonPrinting": true}), json!({"viewAfterSaving": true}), json!({"compression": {"compressText": false}}), json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}), diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index b052daac7..e64adf327 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(); } @@ -796,6 +803,34 @@ mod tests { assert!(app.run("ui.savePdfDialog", json!({"compatibility": "1.0"})).is_err(), "bad options are refused"); } + #[test] + fn bleed_and_non_printing_layers_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)); + 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!([]), "the bleed is 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), "the trim box is the artboard, inside the bleed"); + 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"); From b0e39d0912b43f62348ab4c50a61fd1cea383dc1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:45:14 +0700 Subject: [PATCH 2/2] M4.72: PDF export draws printer's marks in Registration around each page document.exportPdf {marks} now draws trim marks (Roman or Japanese, at the trim mark weight and offset, outside the bleed), a registration target at the middle of each side, colour bars (process inks, their overprints, the document's spot inks and black tints) and the page information (title, artboard, export date in UTC). Marks print in [Registration] (/Separation /All), the MediaBox grows to hold them and the art is clipped to the BleedBox. The geometry is the shared vectorcraft_doc::marks::PrinterMarks (built on TrimMarks) so print can draw the same marks. The "not drawn yet" warning is gone. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/marks.rs | 218 +++++++++++++++++++++- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/engine/src/cmd/fileio/tests_pdf.rs | 4 +- crates/engine/src/tests_pdfmarks.rs | 16 +- crates/pdf/src/export.rs | 25 ++- crates/pdf/src/lib.rs | 4 +- crates/pdf/src/marks.rs | 69 ++++++- crates/pdf/src/settings.rs | 1 - crates/pdf/src/tests_marks.rs | 73 +++++++- crates/pdf/src/tests_settings.rs | 7 +- crates/ui-egui/src/dialogs/save_pdf.rs | 11 +- docs/mcp.md | 13 ++ 12 files changed, 407 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/marks.rs b/crates/doc/src/marks.rs index ac1500eb4..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)] @@ -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 b08147902..83e6177d5 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 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/tests_pdfmarks.rs b/crates/engine/src/tests_pdfmarks.rs index 2fb23159a..a9266b788 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 394cef204..323cef3a8 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