mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #99 from storytold/pr/m4-pdf-boxes-marks
M4.71–M4.72: PDF bleed and trim boxes, printer's marks
This commit is contained in:
+220
-3
@@ -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<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), ®)));
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
@@ -53,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?, 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
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8>, Vec<String>) {
|
||||
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);
|
||||
}
|
||||
@@ -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<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;
|
||||
@@ -81,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);
|
||||
}
|
||||
@@ -104,6 +106,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
lab_spots: vec![],
|
||||
interpolate: set.standard != Standard::PdfA2b,
|
||||
compression: &set.compression,
|
||||
non_printing: set.include_non_printing || set.create_layers,
|
||||
};
|
||||
// CMYK documents blend in CMYK, as on screen: their groups' blending space is rewritten (see
|
||||
// `cmyk_blending`), and transparency at the top of a page is put in a non-isolated group of
|
||||
@@ -111,14 +114,24 @@ 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, 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 ab = &doc.artboards[i];
|
||||
let r = ab.rect;
|
||||
let size = Size::from_wh(r.width().max(1.0) as f32, r.height().max(1.0) as f32).ok_or(PdfError::BadArtboard(i))?;
|
||||
let mut page = pdf.start_page_with(PageSettings::new(size));
|
||||
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).
|
||||
let at = |b: Rect| krilla::geom::Rect::from_ltrb((b.x0 - m.x0) as f32, (b.y0 - m.y0) as f32, (b.x1 - m.x0) as f32, (b.y1 - m.y0) as f32);
|
||||
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();
|
||||
// krilla's page space is y-down with the origin at the top-left corner, like ours.
|
||||
s.push_transform(&xf(Affine::translate((-r.x0, -r.y0))));
|
||||
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 {
|
||||
@@ -131,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();
|
||||
@@ -232,6 +254,8 @@ struct Exporter<'a> {
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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)
|
||||
}))
|
||||
}
|
||||
@@ -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"),
|
||||
(
|
||||
|
||||
@@ -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<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") && !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<Rect> {
|
||||
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:?}");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user