Files
vectorcraft/crates/pdf/src/tests_marks.rs
T
Hamza 1c80b23745 Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files
record the wall-clock time they were made in, so the same commands run twice
gave different bytes although the pixels matched (#2880):

  PDF   /CreationDate and /ModDate   D:20261018155816 vs D:20261018155817
  PNG   the `Creation Time` tEXt entry
  .vectorcraft  one byte of the stored date
  SVG   carries no time, and already repeated

`metadata::output_time` is the time a file records: `SOURCE_DATE_EPOCH` when it
holds a non-negative whole number of seconds, else the clock, as the
reproducible-builds specification defines it. A date a command is given
(`file.new {created}`, a save's `modified`) still wins over the variable, and a
blank, negative or non-numeric value is ignored so that an export is never
refused over the environment.

`metadata::now_unix` keeps the real clock and now has exactly two callers, both
of which must not be pinnable from the environment: the recovery heartbeat, and
the seed PDF encryption draws on.
2026-10-19 00:37:55 +07:00

245 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));
}
/// 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");
}