Files
vectorcraft/crates/pdf/src/tests/marks.rs
T
dotCooCoo a8a1c82a43 PDF export: warn when a PDF/X file leaves out the document's bleed
Choosing a PDF/X standard without a PDF/X preset writes a file with no bleed
(BleedBox = TrimBox) even when Document Setup sets one, and the export gave no
warning about it. The export now warns when a PDF/X file has no bleed on a side
where the document's bleed has one. The warning comes ahead of the flattening
note, because the status bar shows only the first warning. The code adds a
warning and does not change what is written.

These cases do not warn: the PDF/X presets, Use document bleed settings, a
nonzero bleed on every side the document sets, a document with no bleed, files
that aren't PDF/X files, and Export for Screens rows whose artboards Include
Bleed grows.

The warning has a row in every catalog. The Summary's warning and error rows in
the Save PDF and Print dialogs now go through the catalog, so they show its
translations.
2026-11-08 11:42:47 +07:00

261 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 a_pdfx_file_leaves_out_the_document_bleed_side_by_side() {
let x4 = |bleed: serde_json::Value| settings(json!({"standard": "pdfX4", "bleed": bleed}));
let mut d = doc();
d.setup.bleed = [6.0, 0.0, 0.0, 0.0];
assert!(x4(json!({})).leaves_out_bleed(&d));
assert!(x4(json!({"bottom": 1})).leaves_out_bleed(&d), "the top is left out");
assert!(!x4(json!({"top": 1})).leaves_out_bleed(&d), "the sides without a document bleed don't count");
assert!(!x4(json!({"useDocument": true})).leaves_out_bleed(&d));
assert!(!settings(json!({})).leaves_out_bleed(&d), "not a PDF/X file");
// A document bleed read from a file counts as it is kept in range.
d.setup.bleed = [f64::INFINITY, f64::NAN, -3.0, 0.0];
assert!(!x4(json!({})).leaves_out_bleed(&d));
assert!(!x4(json!({"useDocument": true})).leaves_out_bleed(&d));
}
/// Each page's `UserUnit` and media box as the file holds them (in its units).
fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> {
let file = crate::pages::open(bytes, None).unwrap();
file.pages().iter().map(|p| (p.user_unit(), Rect::new(p.media_box().x0, p.media_box().y0, p.media_box().x1, p.media_box().y1))).collect()
}
/// A page with a side longer than ISO 32000-1's 14,400 units is written in larger units (its
/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points.
/// Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep their sizes in points.
#[test]
fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() {
let mut d = doc();
d.artboards[0].rect = Rect::new(0.0, 0.0, 20_000.0, 10_000.0);
let mut small = d.artboards[0].clone();
small.rect = Rect::new(0.0, 11_000.0, 14_400.0, 11_300.0);
d.artboards.push(small.clone());
small.rect = Rect::new(0.0, 12_000.0, 20_000.0, 12_001.0);
d.artboards.push(small);
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.01);
let bytes = pdf(&d, json!({}));
let pages = units_and_media(&bytes);
// 20,000 / 14,400 = 1.3889, rounded up to 1.389: the page is 14,398.85 × 7,199.42 units.
assert_eq!(pages[0].0, 1.389, "{pages:?}");
assert!(pages[0].1.width() <= 14_400.0 && near(pages[0].1.scale_from_origin(1.389), Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{pages:?}");
assert_eq!(pages[1], (1.0, Rect::new(0.0, 0.0, 14_400.0, 300.0)), "a page of 14,400 pt keeps its points");
assert!(pages[2].0 == 1.389 && (pages[2].1.height() * 1.389 - 1.0).abs() < 1e-3, "a side of 1 pt stays 1 pt: {pages:?}");
assert_eq!(String::from_utf8_lossy(&bytes).matches("/UserUnit").count(), 2);
// Read back in points: the page, its trim box and the square reaching 5 pt left of the page.
let page = &info(&bytes, None).unwrap().pages[0];
assert!((page.width - 20_000.0).abs() < 0.01 && (page.height - 10_000.0).abs() < 0.01, "{page:?}");
assert!(near(page_box(&bytes, CropTo::Trim), Rect::new(0.0, 0.0, 20_000.0, 10_000.0)));
let back = import(&bytes).unwrap();
assert!(near(back.artboards[0].rect, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{:?}", back.artboards[0].rect);
let red = painted(&back, |p| rgb(p, [255, 0, 0]));
assert!(red.len() == 1 && near(red[0], Rect::new(-5.0, 10.0, 40.0, 50.0)), "{red:?}");
// PDF 1.5 and PDF/X-4 files have the page in points, past 14,400.
for v in [json!({"compatibility": "1.5"}), json!({"standard": "pdfX4", "compatibility": "1.6"})] {
let pages = units_and_media(&pdf(&d, v.clone()));
assert_eq!(pages[0], (1.0, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{v}");
}
}
#[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:?}");
}
#[test]
fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
let d = doc();
// The outlines of the page information of a PDF of `d` created at `created`.
let info = |created: i64| {
let opts = PdfOptions { settings: settings(json!({"marks": {"pageInfo": true}})), created: Some(created), ..PdfOptions::uncompressed() };
let back = import(&export_with_report(&d, &opts).unwrap().bytes).unwrap();
let mut out = vec![];
back.walk(|n| {
if n.appearance.fill().is_some_and(|f| f.paint.is_registration())
&& let Some(p) = n.path_data()
{
out.push(p.clone());
}
});
out
};
let now = || vectorcraft_doc::metadata::output_time().unwrap();
// Both print the minute they are written in: try again when the minute turns between them.
for _ in 0..3 {
let minute = now() / 60;
let (old, new) = (info(1_577_836_800), info(now()));
if now() / 60 == minute {
assert!(!old.is_empty());
assert!(old == new, "a file created in 2020 prints the same date as one created now");
return;
}
}
panic!("the minute turned during each of three tries");
}