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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 13:05:20 +07:00
1 parent 8fb1733f05
commit b0e39d0912
12 files changed
+407 -36

No files matched your search

+215 -3
View File
@@ -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<Point>,
}
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<Paint> = (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<Arc<Node>> =
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), &reg)));
children.extend(m.rings.into_iter().map(|(c, r)| shape(alloc(), shapes::ellipse(Rect::from_center_size(c, (2.0 * r, 2.0 * r))), &reg)));
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");
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+2 -2
View File
@@ -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();
+15 -1
View File
@@ -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();
+21 -4
View File
@@ -68,7 +68,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into());
if !title.is_empty() {
meta = meta.title(title);
meta = meta.title(title.clone());
}
// File Info.
let info = &doc.metadata;
@@ -82,7 +82,8 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
if !info.keywords.is_empty() {
meta = meta.keywords(info.keywords.clone());
}
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix).map(date_time);
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
let created = created_at.map(date_time);
if let Some(t) = created {
meta = meta.creation_date(t);
}
@@ -113,10 +114,12 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let cmyk = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let page_group = doc.page_isolate || doc.page_knockout;
let cmyk_page_group = cmyk && !page_group && doc.layers.iter().any(|l| l.shows_transparency());
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed.
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
// printer's marks around that.
let bleed = set.bleed_of(doc);
let marks = set.marks.printer_marks();
for i in indices {
let boxes = PageBoxes::new(doc.artboards[i].rect, bleed);
let boxes = PageBoxes::new(doc.artboards[i].rect, bleed, &marks);
let (m, r) = (boxes.media, boxes.bleed);
let size = Size::from_wh(m.width().max(1.0) as f32, m.height().max(1.0) as f32).ok_or(PdfError::BadArtboard(i))?;
// The boxes in page space (y-down from the media box's top-left corner, like ours).
@@ -124,6 +127,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let mut page = pdf.start_page_with(PageSettings::new(size).with_trim_box(at(boxes.trim)).with_bleed_box(at(boxes.bleed)));
let mut s = page.surface();
s.push_transform(&xf(Affine::translate((-m.x0, -m.y0))));
// Art reaches as far as the bleed: the marks lie outside it.
let clip = (m != r).then(|| to_path(&r.to_path(0.1))).flatten();
if let Some(clip) = &clip {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
// Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF
// writer has no page group attributes).
if page_group {
@@ -136,6 +144,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
if page_group || cmyk_page_group {
s.pop();
}
if clip.is_some() {
s.pop();
}
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
if let Some(art) = crate::marks::art(doc, &marks, &boxes, bleed, &info) {
let knockout = std::mem::replace(&mut ex.knockout, false);
ex.node(&mut s, &art, m, true);
ex.knockout = knockout;
}
s.pop();
s.finish();
page.finish();
+2 -2
View File
@@ -8,8 +8,8 @@
//! 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, 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
//! 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()`]).
+61 -8
View File
@@ -1,11 +1,32 @@
//! Marks and Bleeds: the boxes of each exported page.
//! 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_doc::Document;
use vectorcraft_doc::marks::outset;
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::PdfSettings;
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
@@ -23,13 +44,45 @@ pub(crate) struct PageBoxes {
pub trim: Rect,
/// The trim box grown by the bleed: the art it holds is kept.
pub bleed: Rect,
/// The sheet: the bleed box.
/// 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]) -> Self {
let r = outset(trim, bleed);
Self { trim, bleed: r, media: r }
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<i64>) -> 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<Node> {
if !marks.any() {
return None;
}
let spots: Vec<Paint> = 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)
}))
}
-1
View File
@@ -443,7 +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"),
(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"),
(
+68 -5
View File
@@ -1,9 +1,10 @@
//! Marks and Bleeds: page boxes, the art kept in the bleed and non-printing layers.
//! 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};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::marks::PrinterMarks;
use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind};
use vectorcraft_geom::{Rect, shapes};
@@ -23,11 +24,11 @@ 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.
/// The PDF of `d` with settings `v`; marks and bleed raise no warnings.
fn pdf(d: &Document, v: serde_json::Value) -> Vec<u8> {
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);
assert!(r.warnings.iter().all(|w| !w.contains("bleed") && !w.contains("marks")), "{:?}", r.warnings);
r.bytes
}
@@ -82,12 +83,26 @@ fn bleed_grows_the_media_box_around_the_trim_box() {
}
#[test]
fn art_in_the_bleed_is_kept() {
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]
@@ -106,3 +121,51 @@ fn non_printing_layers_are_left_out_unless_asked_for() {
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:?}");
}
+3 -4
View File
@@ -74,7 +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!({"output": {"conversion": "destination"}}), "conversion"),
(json!({"output": {"profiles": "all"}}), "ICC"),
(json!({"output": {"trapped": true}}), "trapped"),
@@ -84,11 +83,11 @@ 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:?}");
}
// 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}}),
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
json!({"includeNonPrinting": true}),
json!({"viewAfterSaving": true}),
json!({"compression": {"compressText": false}}),
@@ -99,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"));
}
+6 -5
View File
@@ -757,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));
@@ -804,7 +804,7 @@ mod tests {
}
#[test]
fn bleed_and_non_printing_layers_reach_the_pdf() {
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();
@@ -813,16 +813,17 @@ mod tests {
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!([]), "the bleed is applied");
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), "the trim box is the artboard, inside the bleed");
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();
+13
View File
@@ -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