Files
vectorcraft/crates/pdf/src/tests_marks.rs
T
bflatasticandClaude Opus 5.5 b0e39d0912 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]>
2026-10-05 13:05:20 +07:00

172 lines
8.6 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 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:?}");
}