Files
vectorcraft/crates/pdf/src/tests/print.rs
T
BFlat e76f8d2c88 Merge pull request #7501 from dotCooCoo/fix/export-bakes-symbol-art-effects
Export: live effects in symbol art apply in PDF, EPS, print and DXF
2026-11-15 00:41:46 +07:00

788 lines
41 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, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, GradientStop, 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,
show_video_safe_areas: false,
video_ruler_pixel_aspect_ratio: 1.0,
color: None,
guide_label: 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")));
}
/// The top-left corners of the copies on the first page of `pv`.
fn corners(pv: &PrintPreview) -> Vec<(f64, f64)> {
pv.sheets[0].copies.iter().map(|c| (c[0], c[1])).collect()
}
/// Every `(x, y)` of `xs` and `ys`, across then down.
fn grid(xs: &[f64], ys: &[f64]) -> Vec<(f64, f64)> {
ys.iter().flat_map(|y| xs.iter().map(move |x| (*x, *y))).collect()
}
#[test]
fn copies_per_sheet_fill_the_page_in_a_grid() {
// A 2 × 3.5 in card (144 × 252 pt) on Letter (612 × 792 pt): 4 across and 3 down, the
// 576 × 756 pt grid centered.
let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]);
let pv = preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap();
assert_eq!((pv.pages, pv.sheets.len(), pv.sheets[0].orientation), (1, 1, Orientation::Portrait));
assert_eq!(corners(&pv), grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]));
assert!(pv.sheets[0].copies.iter().all(|c| (c[2] - c[0], c[3] - c[1]) == (144.0, 252.0)), "{:?}", pv.sheets[0].copies);
assert_eq!(pv.sheets[0].trim, [18.0, 18.0, 594.0, 774.0], "the TrimBox holds every copy");
assert!(pv.warnings.is_empty(), "{:?}", pv.warnings);
// The printed page holds the twelve cards there.
let p = pages(&job(&d, json!({"copiesPerSheet": true})).bytes);
assert_eq!(p.len(), 1);
let mut printed: Vec<(f64, f64)> = p[0].1.iter().map(|(r, _)| (r.x0.round(), r.y0.round())).collect();
printed.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
assert_eq!(printed, grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]), "{:?}", p[0].1);
assert!(p[0].1.iter().all(|(r, paint)| rgb8(paint) == [255, 0, 0] && near(*r, Rect::new(r.x0, r.y0, r.x0 + 144.0, r.y0 + 252.0))));
// An 18 pt gutter: 3 across and 3 down, the 468 × 792 pt grid centered.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 18}))).unwrap();
assert_eq!(corners(&pv), grid(&[72.0, 234.0, 396.0], &[0.0, 270.0, 540.0]));
// The placement places the grid, inside the margin.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap();
assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0]));
// Moved 400 pt right, two columns leave the paper: the TrimBox holds the copies still on it.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "placement": {"origin": "topLeft", "x": 400}}))).unwrap();
assert_eq!(pv.sheets[0].trim, [400.0, 0.0, 612.0, 756.0]);
}
#[test]
fn copies_per_sheet_is_off_by_default_and_off_prints_as_before() {
let s = PrintSettings::default();
assert_eq!((s.copies_per_sheet, s.gutter), (false, 0.0));
let v = serde_json::to_value(&s).unwrap();
assert_eq!((&v["copiesPerSheet"], &v["gutter"]), (&json!(false), &json!(0.0)));
assert!(settings(json!({"gutter": -1})).check().is_err() && settings(json!({"gutter": 721})).check().is_err());
// Off, the job is the same file whatever the gutter, and its pages list no copies.
let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]);
assert_eq!(job(&d, json!({})).bytes, job(&d, json!({"copiesPerSheet": false, "gutter": 36})).bytes);
let pv = serde_json::to_value(preview(&d, &settings(json!({}))).unwrap()).unwrap();
assert!(pv["sheets"][0].get("copies").is_none(), "{pv}");
// Tiles print one artboard across pages: copies per sheet is left out.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "scaling": "tileFull"}))).unwrap();
assert!(pv.sheets[0].copies.is_empty() && pv.sheets[0].tile.is_some());
}
#[test]
fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() {
// 700 × 900 pt is larger than Letter either way round.
let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]);
let r = job(&d, json!({"copiesPerSheet": true}));
assert_eq!(r.pages, 1);
// A note on the options; the art that doesn't print is what the job loses.
assert_eq!(r.setting_notes, ["Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"]);
assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings);
assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet");
assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty());
// When one copy fits, it prints alone, centered, whatever the gutter.
let d = doc(400.0, 500.0, &[]);
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 300}))).unwrap();
assert_eq!(corners(&pv), [(106.0, 146.0)]);
// More copies than a sheet takes are refused.
let tiny = doc(1.0, 1.0, &[]);
assert!(preview(&tiny, &settings(json!({"copiesPerSheet": true}))).is_err_and(|e| e.to_string().contains("more than 1000 copies")));
}
#[test]
fn each_copy_prints_with_its_marks() {
let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(1.0, 0.0, 0.0))]);
let set = json!({"copiesPerSheet": true, "marks": {"trim": true}});
let pv = preview(&d, &settings(set.clone())).unwrap();
let copies = pv.sheets[0].copies.len();
assert!(copies > 1, "{pv:?}");
let back = import(&job(&d, set).bytes).unwrap();
let mut marks = 0;
back.walk(|n| marks += usize::from(n.appearance.stroke().is_some_and(|s| s.paint.is_registration())));
assert_eq!(marks, 8 * copies, "8 trim marks around each copy");
}
#[test]
fn every_copy_on_a_sheet_draws_the_same_symbol_instances() {
// A symbol of three squares (4 objects with its group), placed twice.
let mut d = doc(100.0, 100.0, &[]);
let squares = (0..3)
.map(|i| {
let r = Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0);
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0)))
})
.collect();
let art = Node::group(d.alloc_id(), squares);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Squares".into(), art: Arc::new(art) });
let layer = d.default_layer().unwrap();
for y in [10.0, 50.0] {
let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) };
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, instance)).unwrap();
}
let set = PdfSettings { compression: CompressionSettings { compress_text: false, ..Default::default() }, ..Default::default() };
let mut w = crate::export::Writer::new(&d, &set, "Copies", None, false).unwrap();
let mut ex = crate::export::Exporter::new(&d, &set);
// Room for 10 symbol objects: the first copy could write both instances (8) and leave the
// second copy none.
ex.symbol_limit = 10;
let copies = [vectorcraft_geom::Vec2::ZERO, vectorcraft_geom::Vec2::new(200.0, 0.0)];
let page = Rect::new(0.0, 0.0, 300.0, 100.0);
let sheet = crate::export::Sheet {
size: (300.0, 100.0),
unit: 1.0,
trim: page,
bleed: page,
view: vectorcraft_geom::Affine::IDENTITY,
window: None,
place: vectorcraft_geom::Affine::IDENTITY,
copies: &copies,
area: Rect::new(0.0, 0.0, 100.0, 100.0),
clip: false,
marks: None,
negative: false,
links: false,
};
w.page(&mut ex, &sheet).unwrap();
assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet");
w.absorb(ex);
let (bytes, ..) = w.finish(&mut std::collections::HashSet::new(), false).unwrap();
let p = pages(&bytes);
let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count();
assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1);
}
#[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 a_job_of_too_many_artboards_is_refused_while_laying_out() {
let mut d = doc(100.0, 100.0, &[]);
d.artboards.extend((2..=MAX_PAGES as u32 + 2).map(|i| artboard(i, 0.0)));
let e = preview(&d, &settings(json!({}))).unwrap_err().to_string();
assert!(e.contains("more than 2000 pages"), "{e}");
}
#[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 { dynamic: false, 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 { dynamic: false, 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 symbol_art_effects_print_where_they_draw() {
// A red square and the symbol's blue square, each moved 100 pt right by a Transform effect,
// print at the same distance from the page's left edge.
let moved = |mut n: Node| {
n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true });
n
};
let mut d = doc(200.0, 100.0, &[]);
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0));
d.insert(Some(layer), 0, moved(red)).unwrap();
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) });
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
let p = pages(&job(&d, json!({})).bytes);
assert_eq!(p.len(), 1);
let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0);
let (red, blue) = (left([255, 0, 0]), left([0, 0, 255]));
assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{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");
}
/// A 100 pt CMYK document with spot swatch `Gold` and a square filled with gradient `g`.
fn gold_gradient_doc(g: GradientPaint) -> Document {
let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), Paint::Gradient(Box::new(g)))]);
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 });
d
}
/// The grey of each plate of a separation of `d` (job options `v`), in percent: of the first
/// gradient stop, and of the first freeform point when there is one. No plate keeps a link or
/// process plates.
fn gradient_plates(d: &Document, v: serde_json::Value) -> Vec<(f32, Option<f32>)> {
let opts = PrintOptions { settings: settings(v), ..Default::default() };
let gray = |c: &Color| match c {
Color::Gray { k } => (k * 100.0).round(),
c => panic!("{c:?}"),
};
let plan = plan(d, &opts).unwrap();
plan.plates
.iter()
.map(|p| {
let mut found = vec![];
p.walk(|n| {
if let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) {
let s = &g.gradient.stops[0];
assert_eq!((s.swatch.as_deref(), s.tint, s.plates), (None, 1.0, None), "the stop is unlinked");
found.push((gray(&s.color), g.freeform.as_ref().and_then(|f| f.points.first()).map(|q| gray(&q.color))));
}
});
assert_eq!(found.len(), 1);
found[0]
})
.collect()
}
#[test]
fn a_spot_gradient_stop_prints_its_process_plates() {
let mut mixed = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05));
(mixed.swatch, mixed.tint, mixed.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0]));
let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![mixed, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))]));
let d = gold_gradient_doc(g);
let first = |v| gradient_plates(&d, v).into_iter().map(|(s, _)| s).collect::<Vec<_>>();
assert_eq!(first(json!({"output": {"mode": "separations"}})), [25.0, 0.0, 40.0, 0.0, 50.0], "Cyan, Magenta, Yellow, Black, Gold");
// Spots as process: the plates layered with the gold tint's process inks (0, 10, 40, 5).
assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 10.0, 64.0, 5.0]);
}
#[test]
fn a_freeform_gradients_points_separate_into_plates() {
let mut g = GradientPaint::new(Gradient::new(GradientKind::Freeform, vec![GradientStop::new(0.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))]));
let point = FreeformPoint::new(vectorcraft_geom::Point::new(25.0, 25.0), Color::cmyk(0.0, 0.6, 0.0, 0.0));
g.freeform = Some(Freeform { points: vec![point], ..Default::default() });
let d = gold_gradient_doc(g);
let set = json!({"output": {"mode": "separations"}});
let points = gradient_plates(&d, set.clone()).into_iter().map(|(_, p)| p).collect::<Vec<_>>();
assert_eq!(points, [Some(0.0), Some(60.0), Some(0.0), Some(0.0), Some(0.0)], "the point's magenta, on its plate only");
// Each plate draws the square in the grey of the point's ink on it.
let plan = plan(&d, &PrintOptions { settings: settings(set), ..Default::default() }).unwrap();
let drawn: Vec<u8> = plan.plates.iter().map(|p| render_region(p, Rect::new(0.0, 0.0, 50.0, 50.0), 1.0).over_white(25, 25)[0]).collect();
for (plate, (v, ink)) in drawn.iter().zip([0.0, 0.6, 0.0, 0.0, 0.0]).enumerate() {
assert!((f32::from(*v) - 255.0 * (1.0 - ink)).abs() <= 3.0, "plate {plate}: {drawn:?}");
}
}
#[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 for_a_printer_landscape_pages_print_upright_turned_anticlockwise() {
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 s = json!({"placement": {"origin": "topLeft"}, "autoRotate": false, "orientation": "landscape"});
let spooled = |v| {
let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, for_printer: true, ..Default::default() };
pages(&print(&d, &opts).unwrap().bytes).remove(0)
};
// Saved, the page stays landscape.
assert_eq!(pages(&job(&d, s.clone()).bytes)[0].0, (792.0, 612.0));
// For a printer, Letter upright: the art's top at the paper's left edge, its left half at the bottom.
let p = spooled(s.clone());
assert_eq!(p.0, (612.0, 792.0));
assert!(near(p.1[0].0, Rect::new(0.0, 742.0, 100.0, 792.0)), "{:?}", p.1);
// A portrait page and a transverse one (already upright) don't turn.
let p = spooled(json!({"placement": {"origin": "topLeft"}, "autoRotate": false}));
assert_eq!(p.0, (612.0, 792.0));
assert!(near(p.1[0].0, Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", p.1);
let mut t = s;
t["transverse"] = json!(true);
assert_eq!(spooled(t).0, (612.0, 792.0));
// Transverse in Portrait is wide on purpose: it stays wide.
assert_eq!(spooled(json!({"autoRotate": false, "transverse": true})).0, (792.0, 612.0));
}
#[test]
fn for_a_printer_paper_wide_in_portrait_stays_as_it_is() {
// A 4 × 2 in label in Portrait: the queue's media is wide too.
let d = doc(288.0, 144.0, &[(Rect::new(0.0, 0.0, 144.0, 144.0), rgb(1.0, 0.0, 0.0))]);
let v =
json!({"media": "custom", "width": 288, "height": 144, "orientation": "portrait", "autoRotate": false, "placement": {"origin": "topLeft"}});
let opts = PrintOptions { settings: settings(v), uncompressed: true, for_printer: true, ..Default::default() };
let p = pages(&print(&d, &opts).unwrap().bytes).remove(0);
assert_eq!(p.0, (288.0, 144.0));
assert!(near(p.1[0].0, Rect::new(0.0, 0.0, 144.0, 144.0)), "{:?}", p.1);
}
#[test]
fn for_a_printer_auto_rotated_artboards_all_print_upright() {
// A wide artboard and a tall one, the paper turned to each.
let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 10.0, 10.0), rgb(1.0, 0.0, 0.0))]);
let mut wide = artboard(1, 0.0);
wide.rect = Rect::new(0.0, 0.0, 300.0, 100.0);
let mut tall = artboard(2, 400.0);
tall.rect = Rect::new(400.0, 0.0, 500.0, 300.0);
d.artboards = vec![wide, tall];
let set = settings(json!({"autoRotate": true}));
let saved = print(&d, &PrintOptions { settings: set.clone(), ..Default::default() }).unwrap();
let sizes = |bytes: &[u8]| pages(bytes).into_iter().map(|p| p.0).collect::<Vec<_>>();
assert_eq!(sizes(&saved.bytes), [(792.0, 612.0), (612.0, 792.0)]);
let spooled = print(&d, &PrintOptions { settings: set, for_printer: true, ..Default::default() }).unwrap();
assert_eq!(sizes(&spooled.bytes), [(612.0, 792.0), (612.0, 792.0)]);
}
#[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");
}