Files
vectorcraft/crates/pdf/src/tests_print.rs
T
2026-10-21 23:38:02 +07:00

497 lines
25 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.
//! File → Print as a PDF: paper, scale, placement, tiles, artboard ranges, copies, layers,
//! separations, marks and the page turns.
use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::{Appearance, Artboard, Document, LayerColor, Node, NodeKind};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::raster::render_region;
use crate::*;
/// A `w` × `h` document with filled rectangles `(rect, paint)` on its one layer.
fn doc(w: f64, h: f64, shapes_: &[(Rect, Paint)]) -> Document {
let mut d = Document::new(w, h);
let layer = d.default_layer().unwrap();
for (r, p) in shapes_ {
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(*r), Appearance::basic(p.clone(), Paint::None, 0.0))).unwrap();
}
d
}
fn artboard(id: u32, x: f64) -> Artboard {
Artboard {
id,
name: format!("Artboard {id}"),
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
}
}
fn rgb(r: f32, g: f32, b: f32) -> Paint {
Paint::solid(Color::rgb(r, g, b))
}
fn settings(v: serde_json::Value) -> PrintSettings {
serde_json::from_value(v).unwrap()
}
fn job(d: &Document, v: serde_json::Value) -> PrintReport {
let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() };
print(d, &opts).unwrap()
}
/// A printed page read back: its size and every filled path on it (bounds in page space, y
/// down; paint), left to right.
type Page = ((f64, f64), Vec<(Rect, Paint)>);
fn pages(bytes: &[u8]) -> Vec<Page> {
let back = import(bytes).unwrap();
back.artboards
.iter()
.map(|ab| {
let r = ab.rect;
let mut paths = vec![];
back.walk(|n| {
if let (Some(b), Some(f)) = (n.path_data().and_then(|p| p.bounds()), n.appearance.fill())
&& f.paint.color().is_some()
&& b.intersect(r).area() > 0.0
{
paths.push((b - r.origin().to_vec2(), f.paint.clone()));
}
});
paths.sort_by(|a, b| a.0.x0.total_cmp(&b.0.x0));
((r.width(), r.height()), paths)
})
.collect()
}
fn near(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05)
}
fn rgb8(p: &Paint) -> [u8; 3] {
let c = p.color().unwrap().to_rgba8(1.0);
[c[0], c[1], c[2]]
}
#[test]
fn defaults_and_json() {
let s = PrintSettings::default();
assert_eq!(
(s.copies, s.collate, s.media, s.scaling, s.print_layers),
(1, true, Media::Letter, PrintScaling::None, PrintLayers::VisiblePrintable)
);
assert!(s.auto_rotate && s.bleed.use_document && s.check().is_ok());
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["printLayers"], "visiblePrintable");
assert_eq!(v["output"]["mode"], "composite");
assert_eq!(serde_json::from_value::<PrintSettings>(v).unwrap(), s);
// Every field is optional.
let s = settings(json!({"scaling": "tileFull", "placement": {"origin": "topLeft"}, "color": {"intent": "perceptual"}}));
assert_eq!((s.scaling, s.placement.origin, s.copies), (PrintScaling::TileFull, Origin::TopLeft, 1));
for bad in [json!({"copies": 0}), json!({"scale": {"width": 0}}), json!({"media": "custom", "width": 10}), json!({"marks": {"weight": 9}})] {
assert!(matches!(settings(bad.clone()).check(), Err(PdfError::BadSetting(_))), "{bad}");
}
assert_eq!(Media::A4.size().map(|(w, h)| (w.round(), h.round())), Some((595.0, 842.0)));
}
#[test]
fn japanese_paper_sizes() {
// The Japanese papers: JIS B4, B5 and B6 are the sizes Japanese printing uses (JIS X 6310),
// a little larger than ISO's; the others are はがき, 往復はがき, 封筒 and 四六判.
let near = |m: Media, w: f64, h: f64| {
let (mw, mh) = m.size().unwrap();
assert!((mw - w).abs() < 0.01 && (mh - h).abs() < 0.01, "{m:?} is {mw} × {mh}, not {w} × {h}");
};
near(Media::B4Jis, 728.50, 1031.81);
near(Media::B5Jis, 515.91, 728.50);
near(Media::B6Jis, 362.83, 515.91);
near(Media::A6, 297.64, 419.53);
near(Media::JapanesePostcard, 283.46, 419.53);
near(Media::JapaneseDoublePostcard, 419.53, 566.93);
near(Media::JapaneseEnvelope3, 340.16, 666.14);
near(Media::JapaneseEnvelope4, 255.12, 637.80);
near(Media::Kaku2, 680.31, 941.10);
near(Media::Kaku3, 612.28, 785.20);
near(Media::YonrokuBan, 360.0, 532.91);
// The ISO B sizes keep the dimensions settings and presets saved before the Japanese papers
// existed: they are not the JIS sizes.
near(Media::B4, 708.66, 1000.63);
near(Media::B5, 498.90, 708.66);
// A paper is named by its JSON id or its dialog label, any case and spacing.
for (name, want) in [
("b4 (jis)", Media::B4Jis),
("B4 (JIS)", Media::B4Jis),
("b4jis", Media::B4Jis),
("b5", Media::B5),
("B5 (ISO)", Media::B5),
("b5 (jis)", Media::B5Jis),
("japanese postcard", Media::JapanesePostcard),
("Japanese Postcard", Media::JapanesePostcard),
("Japanese Envelope #3", Media::JapaneseEnvelope3),
("kaku #2", Media::Kaku2),
("yonroku-ban", Media::YonrokuBan),
] {
assert_eq!(Media::from_name(name), Some(want), "{name}");
}
assert_eq!(Media::from_name("b7"), None);
assert_eq!(Media::from_name(" "), None);
for m in Media::ALL {
assert_eq!(Media::from_name(m.id()), Some(*m), "id {:?}", m.id());
assert_eq!(Media::from_name(m.label()), Some(*m), "label {:?}", m.label());
assert_eq!(serde_json::to_value(m).unwrap(), json!(m.id()));
assert_eq!(serde_json::from_value::<Media>(json!(m.id())).unwrap(), *m);
}
assert!(serde_json::from_value::<Media>(json!("b7")).is_err(), "an unknown paper is refused");
// Every paper prints as a page of its own size, the added ones included.
let d = doc(50.0, 50.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), rgb(0.0, 0.0, 0.0))]);
for (media, w, h) in [
("japanese postcard", 283.46, 419.53),
("japanese double postcard", 419.53, 566.93),
("B5 (JIS)", 515.91, 728.50),
("kaku #2", 680.31, 941.10),
("yonroku-ban", 360.0, 532.91),
// The ISO B sizes keep their page size.
("b5", 498.90, 708.66),
] {
let r = job(&d, json!({"media": media, "autoRotate": false}));
assert_eq!(r.pages, 1, "{media}");
let p = pages(&r.bytes);
assert_eq!(p.len(), 1, "{media}");
assert!((p[0].0.0 - w).abs() < 0.01 && (p[0].0.1 - h).abs() < 0.01, "{media}: {:?}, not {w} × {h}", p[0].0);
}
}
#[test]
fn fit_to_letter_scales_the_artboard_into_the_paper() {
// Twice Letter, landscape, a red square in its top-left quarter.
let d = doc(1584.0, 1224.0, &[(Rect::new(0.0, 0.0, 792.0, 612.0), rgb(1.0, 0.0, 0.0))]);
let r = job(&d, json!({"scaling": "fit"}));
assert_eq!(r.pages, 1);
let p = pages(&r.bytes);
assert_eq!(p[0].0, (792.0, 612.0), "auto-rotate turns Letter landscape");
assert!(near(p[0].1[0].0, Rect::new(0.0, 0.0, 396.0, 306.0)), "half size from the corner: {:?}", p[0].1);
// Without auto-rotate a portrait Letter fits it smaller, centred.
let r = job(&d, json!({"scaling": "fit", "autoRotate": false}));
let p = pages(&r.bytes);
assert_eq!(p[0].0, (612.0, 792.0));
let s = 612.0 / 1584.0;
let top = (792.0 - 1224.0 * s) / 2.0;
assert!(near(p[0].1[0].0, Rect::new(0.0, top, 792.0 * s, top + 612.0 * s)), "{:?}", p[0].1);
let pv = preview(&d, &settings(json!({"scaling": "fit", "autoRotate": false}))).unwrap();
assert!((pv.sheets[0].scale[0] - s * 100.0).abs() < 1e-9 && pv.sheets[0].orientation == Orientation::Portrait);
// With marks the artboard shrinks to leave them room on the paper.
let pv = preview(&d, &settings(json!({"scaling": "fit", "marks": {"trim": true}}))).unwrap();
assert!(pv.sheets[0].scale[0] < 50.0 && pv.warnings.is_empty(), "{pv:?}");
}
#[test]
fn placement_and_custom_scale() {
let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(0.0, 0.0, 1.0))]);
let at = |v| pages(&job(&d, v).bytes)[0].1[0].0;
assert!(near(at(json!({})), Rect::new(256.0, 346.0, 356.0, 446.0)), "centred");
assert!(near(at(json!({"placement": {"origin": "topLeft", "x": 10, "y": 20}})), Rect::new(10.0, 20.0, 110.0, 120.0)));
assert!(near(at(json!({"placement": {"origin": "bottomRight"}, "margin": 18})), Rect::new(494.0, 674.0, 594.0, 774.0)), "inside the margin");
assert!(near(
at(json!({"placement": {"origin": "topLeft"}, "scaling": "custom", "scale": {"width": 200, "height": 50}})),
Rect::new(0.0, 0.0, 200.0, 50.0)
));
// Art larger than the paper is cut off, with a warning.
let big = doc(1000.0, 100.0, &[]);
assert!(job(&big, json!({"autoRotate": false})).warnings.iter().any(|w| w.contains("larger than the imageable area")));
}
#[test]
fn tiles_cover_the_artboard_with_overlap() {
// 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1.
let d = doc(1000.0, 700.0, &[(Rect::new(560.0, 100.0, 600.0, 140.0), rgb(0.0, 0.6, 0.0))]);
let set = json!({"scaling": "tileFull", "overlap": 36, "autoRotate": false});
let pv = preview(&d, &settings(set.clone())).unwrap();
assert_eq!((pv.pages, pv.tiles[0].columns, pv.tiles[0].rows), (2, 2, 1));
assert_eq!(pv.tiles[0].tiles[1][0], 576.0, "the second tile starts a tile less the overlap along");
let p = pages(&job(&d, set).bytes);
assert_eq!(p.len(), 2);
// The square lies in the overlap: on both pages, where each tile shows it.
assert!(near(p[0].1[0].0, Rect::new(560.0, 100.0, 600.0, 140.0)), "{:?}", p[0].1);
assert!(near(p[1].1[0].0, Rect::new(-16.0, 100.0, 24.0, 140.0)), "{:?}", p[1].1);
// 1100 × 1400: 2 × 2, and a tile range.
let d = doc(1100.0, 1400.0, &[]);
let pv = preview(&d, &settings(json!({"scaling": "tileFull", "overlap": 36, "autoRotate": false}))).unwrap();
assert_eq!((pv.pages, pv.tiles[0].columns, pv.tiles[0].rows), (4, 2, 2));
let pv = preview(&d, &settings(json!({"scaling": "tileFull", "tileRange": "2-3", "autoRotate": false}))).unwrap();
assert_eq!((pv.pages, pv.tiles[0].printed.clone()), (2, vec![2, 3]));
assert_eq!(pv.sheets.iter().map(|s| s.tile).collect::<Vec<_>>(), [Some(2), Some(3)]);
assert!(preview(&d, &settings(json!({"scaling": "tileFull", "tileRange": "9"}))).is_err());
// Imageable-area tiles are smaller (inside the margin).
let pv = preview(&d, &settings(json!({"scaling": "tileImageable", "margin": 72, "autoRotate": false}))).unwrap();
assert_eq!((pv.tiles[0].columns, pv.tiles[0].rows), (3, 3));
assert!(preview(&d, &settings(json!({"scaling": "tileFull", "overlap": 400}))).is_err(), "overlap of half a tile");
}
#[test]
fn artboard_range_skip_blank_and_ignore() {
let mut d =
doc(100.0, 100.0, &[(Rect::new(10.0, 10.0, 20.0, 20.0), rgb(1.0, 0.0, 0.0)), (Rect::new(410.0, 10.0, 420.0, 20.0), rgb(0.0, 0.0, 1.0))]);
d.artboards.extend([artboard(2, 200.0), artboard(3, 400.0)]);
assert_eq!(job(&d, json!({})).pages, 3);
let p = pages(&job(&d, json!({"artboards": "range", "range": "3, 1"})).bytes);
assert_eq!(p.len(), 2);
assert_eq!(rgb8(&p[0].1[0].1), [0, 0, 255], "in the range's order");
assert_eq!(rgb8(&p[1].1[0].1), [255, 0, 0]);
assert!(preview(&d, &settings(json!({"artboards": "range", "range": "4"}))).is_err());
let pv = preview(&d, &settings(json!({"skipBlank": true}))).unwrap();
assert_eq!(pv.sheets.iter().map(|s| s.artboard).collect::<Vec<_>>(), [Some(0), Some(2)], "the middle artboard is blank");
// Ignoring artboards: all the art on one page.
let pv = preview(&d, &settings(json!({"artboards": "ignore"}))).unwrap();
assert_eq!((pv.pages, pv.sheets[0].artboard), (1, None));
assert!(preview(&doc(100.0, 100.0, &[]), &settings(json!({"artboards": "ignore"}))).is_err(), "no art");
}
#[test]
fn a_symbol_instance_prints_by_its_art() {
// The symbol's red rectangle, (100, 100) to (140, 140), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc(100.0, 100.0, &[]);
let red = Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), red);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
let xf = vectorcraft_geom::Affine::translate((-50.0, -50.0));
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf })).unwrap();
// Skip Blank Artboards keeps the artboard.
assert_eq!(preview(&d, &settings(json!({"skipBlank": true}))).unwrap().sheets.len(), 1);
// Ignoring artboards, the page holds the art.
let p = pages(&job(&d, json!({"artboards": "ignore"})).bytes);
assert_eq!(p.len(), 1);
let printed: Vec<_> = p[0].1.iter().map(|(r, paint)| (r.width().round(), r.height().round(), rgb8(paint))).collect();
assert_eq!(printed, [(40.0, 40.0, [255, 0, 0])], "{p:?}");
}
#[test]
fn a_symbol_instances_brush_art_keeps_its_artboard_printed() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
// round nib. Drawn from (-5, 0), the line lies left of the artboard, and the nib paints 10 pt
// either side of it, onto the artboard.
let mut d = doc(100.0, 100.0, &[]);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut stroke = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let art = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(art) });
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
let xf = vectorcraft_geom::Affine::translate((-5.0, 0.0));
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Nib".into(), xf })).unwrap();
// Skip Blank Artboards keeps the artboard, and its page holds the nib's art.
let p = pages(&job(&d, json!({"skipBlank": true})).bytes);
assert_eq!(p.len(), 1);
assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}");
}
#[test]
fn copies_collate_and_reverse() {
let mut d =
doc(100.0, 100.0, &[(Rect::new(10.0, 10.0, 20.0, 20.0), rgb(1.0, 0.0, 0.0)), (Rect::new(210.0, 10.0, 220.0, 20.0), rgb(0.0, 0.0, 1.0))]);
d.artboards.push(artboard(2, 200.0));
let order = |v| pages(&job(&d, v).bytes).iter().map(|p| if rgb8(&p.1[0].1) == [255, 0, 0] { 'a' } else { 'b' }).collect::<String>();
assert_eq!(order(json!({"copies": 2})), "abab");
assert_eq!(order(json!({"copies": 2, "collate": false})), "aabb");
assert_eq!(order(json!({"copies": 2, "reverse": true})), "baba");
assert!(preview(&d, &settings(json!({"copies": 999}))).is_ok_and(|p| p.pages == 1998));
assert!(preview(&d, &settings(json!({"copies": 999, "output": {"mode": "separations"}}))).is_err(), "too many pages");
}
#[test]
fn print_layers_and_template_layers() {
let mut d = doc(200.0, 200.0, &[(Rect::new(0.0, 0.0, 10.0, 10.0), rgb(1.0, 0.0, 0.0))]);
// A hidden layer, a non-printing one and a template, each with a blue square.
for (i, name) in ["Hidden", "Notes", "Template"].into_iter().enumerate() {
let mut layer = Node::layer(d.alloc_id(), name, LayerColor::Preset(1));
let x = 20.0 + 20.0 * i as f64;
let sq =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0));
if let NodeKind::Layer { children, printable, template, .. } = &mut layer.kind {
*printable = name != "Notes";
*template = name == "Template";
children.push(Arc::new(sq));
}
layer.visible = name != "Hidden";
d.layers.push(Arc::new(layer));
}
let blues = |v| pages(&job(&d, v).bytes)[0].1.iter().filter(|(_, p)| rgb8(p) == [0, 0, 255]).count();
assert_eq!(blues(json!({})), 0, "visible & printable: the hidden and non-printing layers are left out");
assert_eq!(blues(json!({"printLayers": "visible"})), 1, "the non-printing layer");
assert_eq!(blues(json!({"printLayers": "all"})), 2, "the hidden layer too, never the template");
}
#[test]
fn cmyk_and_a_spot_separate_into_five_plates() {
let mut d = doc(200.0, 100.0, &[]);
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
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 layer = d.default_layer().unwrap();
let cyan = Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0));
let gold = Paint::Solid { color: Color::cmyk(0.0, 0.1, 0.4, 0.05), swatch: Some("Gold".into()), tint: 0.5 };
for (r, p) in [(Rect::new(0.0, 0.0, 50.0, 50.0), cyan), (Rect::new(100.0, 0.0, 150.0, 50.0), gold)] {
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(r), Appearance::basic(p, Paint::None, 0.0))).unwrap();
}
let set = json!({"output": {"mode": "separations"}, "marks": {"trim": true}});
let pv = preview(&d, &settings(set.clone())).unwrap();
assert_eq!(pv.inks.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
assert_eq!(pv.sheets.iter().filter_map(|s| s.ink.clone()).collect::<Vec<_>>(), ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
assert_eq!((pv.inks[0].angle, pv.inks[3].angle, pv.inks[0].frequency), (15.0, 45.0, DEFAULT_FREQUENCY));
let r = job(&d, set);
assert_eq!(r.pages, 5);
assert!(!String::from_utf8_lossy(&r.bytes).contains("/Separation"), "plates are grey");
// The ink of the cyan and the gold square on a plate, in percent (0: none, a knockout).
let inks = |page: &Page| -> Vec<f32> {
page.1
.iter()
.filter(|(b, _)| (b.width() - 50.0).abs() < 1.0 && (b.height() - 50.0).abs() < 1.0)
.map(|(_, p)| match p.color() {
Some(Color::Gray { k }) => (k * 100.0).round(),
c => panic!("{c:?}"),
})
.collect()
};
let p = pages(&r.bytes);
assert_eq!(inks(&p[0]), [100.0, 0.0], "cyan plate");
assert_eq!(inks(&p[1]), [0.0, 0.0], "magenta plate: both knock out");
assert_eq!(inks(&p[4]), [0.0, 50.0], "the spot plate at the tint");
// Trim marks print on every plate, in full ink.
let back = import(&r.bytes).unwrap();
let mut strokes = 0;
back.walk(|n| strokes += usize::from(n.appearance.stroke().is_some_and(|s| matches!(s.paint.color(), Some(Color::Gray { k }) if k > 0.99))));
assert_eq!(strokes, 8 * 5, "8 trim marks on each plate");
// Spots as process: four plates, the gold in process inks; an ink can be left out.
let pv = preview(&d, &settings(json!({"output": {"mode": "separations", "spotsToProcess": true, "inks": [{"name": "Black", "print": false}]}})))
.unwrap();
assert_eq!(pv.pages, 3);
let p = pages(&job(&d, json!({"output": {"mode": "separations", "spotsToProcess": true}})).bytes);
assert_eq!(p.len(), 4);
assert_eq!(inks(&p[2]), [0.0, 40.0], "the gold's yellow");
}
#[test]
fn trim_marks_surround_the_artboard_on_the_paper() {
let d = doc(200.0, 100.0, &[(Rect::new(0.0, 0.0, 200.0, 100.0), rgb(1.0, 0.0, 0.0))]);
let r = job(&d, json!({"marks": {"trim": true}, "autoRotate": false}));
let pdf = info(&r.bytes, None).unwrap();
let trim = pdf.pages[0].boxes.iter().find(|(c, _)| *c == CropTo::Trim).unwrap().1;
assert_eq!((trim.width().round(), trim.height().round()), (200.0, 100.0), "the TrimBox is the artboard on the paper");
let back = import(&r.bytes).unwrap();
let ab = Rect::new(206.0, 346.0, 406.0, 446.0) + back.artboards[0].rect.origin().to_vec2();
let mut marks = vec![];
back.walk(|n| {
if n.appearance.stroke().is_some_and(|s| s.paint.is_registration())
&& let Some(b) = n.path_data().and_then(|p| p.bounds())
{
marks.push(b);
}
});
assert_eq!(marks.len(), 8);
assert!(marks.iter().all(|m| m.intersect(ab.inflate(-0.5, -0.5)).area() <= 0.0 && ab.inflate(40.0, 40.0).contains_rect(*m)), "{marks:?}");
// The document's bleed by default: the BleedBox is the artboard grown by it.
let mut bled = d.clone();
bled.setup.bleed = [9.0; 4];
let pdf = info(&job(&bled, json!({"autoRotate": false})).bytes, None).unwrap();
let bleed = pdf.pages[0].boxes.iter().find(|(c, _)| *c == CropTo::Bleed).unwrap().1;
assert_eq!((bleed.width().round(), bleed.height().round()), (218.0, 118.0));
let pdf = info(&job(&bled, json!({"autoRotate": false, "bleed": {"useDocument": false}})).bytes, None).unwrap();
let bleed = pdf.pages[0].boxes.iter().find(|(c, _)| *c == CropTo::Bleed).unwrap().1;
assert_eq!((bleed.width().round(), bleed.height().round()), (200.0, 100.0));
}
#[test]
fn turns_mirror_and_negative() {
let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 100.0), rgb(1.0, 0.0, 0.0))]);
let first = |v| pages(&job(&d, v).bytes).remove(0);
let with = |k: &str, v| {
let mut s = json!({"placement": {"origin": "topLeft"}, "autoRotate": false});
s[k] = v;
s
};
assert!(near(first(with("output", json!({"emulsion": "down"}))).1[0].0, Rect::new(562.0, 0.0, 612.0, 100.0)), "mirrored");
assert!(near(first(with("orientation", json!("portraitFlipped"))).1[0].0, Rect::new(562.0, 692.0, 612.0, 792.0)), "upside down");
assert_eq!(first(with("orientation", json!("landscape"))).0, (792.0, 612.0));
let p = first(with("transverse", json!(true)));
assert_eq!(p.0, (792.0, 612.0), "a quarter turn");
assert!(near(p.1[0].0, Rect::new(692.0, 0.0, 792.0, 50.0)), "{:?}", p.1);
// A negative: black paper, the red square cyan.
let back = import(&job(&d, with("output", json!({"image": "negative"}))).bytes).unwrap();
let img = render_region(&back, back.artboards[0].rect, 1.0);
assert_eq!(img.over_white(300, 300), [0, 0, 0]);
let c = img.over_white(25, 50);
assert!(c[0] < 10 && c[1] > 245 && c[2] > 245, "{c:?}");
}
#[test]
fn golden_page_one() {
// 300 × 200: red left half, blue right half, a black frame; on Letter, turned landscape,
// centred at 100%, with trim marks and registration targets.
let mut d =
doc(300.0, 200.0, &[(Rect::new(0.0, 0.0, 150.0, 200.0), rgb(1.0, 0.0, 0.0)), (Rect::new(150.0, 0.0, 300.0, 200.0), rgb(0.0, 0.0, 1.0))]);
let layer = d.default_layer().unwrap();
let id = d.alloc_id();
d.insert(
Some(layer),
2,
Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)), Appearance::basic(Paint::None, rgb(0.0, 0.0, 0.0), 4.0)),
)
.unwrap();
let r = job(&d, json!({"marks": {"trim": true, "registration": true, "weight": 2}}));
let back = import(&r.bytes).unwrap();
let page = back.artboards[0].rect;
assert_eq!((page.width(), page.height()), (792.0, 612.0));
let img = render_region(&back, page, 1.0);
let (x, y) = (246, 206);
for (px, py, want) in [
(x + 75, y + 100, [255, 0, 0]),
(x + 225, y + 100, [0, 0, 255]),
(x + 10, y + 100, [0, 0, 0]),
(x + 150, y + 10, [0, 0, 0]),
(40, 40, [255, 255, 255]),
(x - 3, y + 100, [255, 255, 255]),
// A trim mark along the top edge, left of the corner past the 6 pt offset.
(x - 12, y, [0, 0, 0]),
] {
let c = img.over_white(px, py);
assert!(c.iter().zip(want).all(|(a, b)| a.abs_diff(b) < 40), "({px}, {py}): {c:?} vs {want:?}");
}
}
/// Separations go through the document's colour spaces: the CMYK profile assigned to it, not the
/// working one (a document opened again in a new session keeps its profile, while the working
/// spaces go back to the defaults).
#[test]
fn separations_use_the_cmyk_profile_assigned_to_the_document() {
let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), rgb(0.5, 0.5, 0.5))]);
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
// The cyan of the grey square, in percent.
let cyan = |d: &Document| -> f32 {
let p = pages(&job(d, json!({"output": {"mode": "separations"}})).bytes);
match p[0].1.iter().find(|(b, _)| (b.width() - 50.0).abs() < 1.0).and_then(|(_, p)| p.color()) {
Some(Color::Gray { k }) => (k * 100.0).round(),
c => panic!("{c:?}"),
}
};
let working = cyan(&d);
assert!(working > 0.0, "the working CMYK profile separates grey into four inks: cyan {working}");
d.color_profiles.cmyk = Some(vectorcraft_color::cms::DEVICE_CMYK.into());
assert_eq!(cyan(&d), 0.0, "Device CMYK puts a neutral grey on the black plate only");
}