Files
craft-libs/crates/prepress/tests/plates.rs
T

277 lines
15 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.
use std::sync::Arc;
use craft_cms::{Intent, Profile, Transform, builtin};
use craft_prepress::{Alternate, CmsConversion, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates};
/// A 40 × 40 pt page with `content` and `resources`, `catalog` entries, and `objects`
/// numbered from 5 (each a whole object body).
fn page_pdf(content: &str, resources: &str, catalog: &str, objects: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
out.extend_from_slice(format!("1 0 obj << /Type /Catalog /Pages 2 0 R {catalog} >> endobj\n").as_bytes());
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
out.extend_from_slice(
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
);
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for (i, body) in objects.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b" endobj\n");
}
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
out
}
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes();
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream");
out
}
/// A spot color whose alternate is a yellowish CMYK.
const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]";
/// Black over the page, Gold at 50% in the middle square, overprinting when `gs` sets `op`.
fn black_and_gold(gs: &str) -> Vec<u8> {
let resources = format!("/ColorSpace << /CS0 {GOLD} >> /ExtGState << /OP << /op true >> >>");
page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} /CS0 cs 0.5 scn 10 10 20 20 re f"), &resources, "", &[])
}
fn black() -> InkId {
InkId::process(Process::Black)
}
#[test]
fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() {
let p = plates(&black_and_gold(""), 0, 72.0, &Options::default()).unwrap();
assert_eq!((p.width, p.height, p.dpi), (40, 40, 72.0));
let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect();
assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
assert_eq!(p.planes.len(), 5);
assert!(p.unseparated.is_empty());
let gold = p.inks.lookup("Gold").unwrap();
let Some(Ink::Spot(spot)) = p.inks.get(gold) else { panic!("Gold is a spot ink") };
let Alternate::Lab([l, _, b]) = spot.alternate else { panic!("a Lab alternate") };
assert!(l > 60.0 && b > 40.0, "Gold looks yellow: {:?}", spot.alternate);
let half = |t: Option<u8>| t.is_some_and(|t| t.abs_diff(128) <= 1);
assert!(half(p.tint(gold, 20, 20)), "Gold at 50%");
assert_eq!(p.tint(black(), 20, 20), Some(0), "Gold knocks out black");
assert_eq!(p.tint(black(), 2, 2), Some(255));
assert_eq!(p.tint(gold, 2, 2), Some(0));
for c in [Process::Cyan, Process::Magenta, Process::Yellow] {
assert!(p.plane(InkId::process(c)).unwrap().iter().all(|t| *t == 0), "{c:?} is empty");
}
let over = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options::default()).unwrap();
assert!(half(over.tint(gold, 20, 20)));
assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted");
let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap();
assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out");
// The preview reads the plates: knocked-out Gold shows pale on paper, overprinted Gold
// darkens the black under it.
let cms = CmsConversion::fogra39();
let px = |rgb: &[u8], x: usize, y: usize| -> [u8; 3] { rgb[(y * 40 + x) * 3..][..3].try_into().unwrap() };
let (knockout, overprint) = (p.composite(&[], &cms), over.composite(&[], &cms));
let [r, g, b] = px(&knockout, 20, 20);
assert!(r > 200 && g > 180 && b < g, "50% Gold on paper: {:?}", [r, g, b]);
let k = px(&knockout, 2, 2);
assert!(k.iter().all(|c| *c < 80), "black: {k:?}");
assert_eq!(px(&overprint, 2, 2), k);
let dark = px(&overprint, 20, 20);
assert!(dark.iter().zip(k).all(|(d, k)| *d <= k) && dark[2] < k[2], "Gold over black: {dark:?}, black {k:?}");
}
/// What `profile` prints for sRGB red, as tint bytes.
fn red_in(profile: &[u8]) -> [u8; 4] {
let t = Transform::new(builtin::srgb(), &Profile::parse(profile).unwrap(), Intent::RelativeColorimetric, false).unwrap();
let mut cmyk = [0.0_f32; 4];
t.convert_f32(&[1.0, 0.0, 0.0], 3, &mut cmyk, 4, false).unwrap();
cmyk.map(|v| (v * 255.0).round() as u8)
}
fn red_tints(p: &craft_prepress::Plates) -> [u8; 4] {
Process::ALL.map(|c| p.tint(InkId::process(c), 20, 20).unwrap())
}
fn near(got: [u8; 4], expected: [u8; 4]) -> bool {
got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= 3)
}
#[test]
fn process_color_converts_through_the_output_intent_else_the_fallback() {
let synthetic = builtin::COATED_CMYK_ICC;
let intent = "/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Synthetic press) /DestOutputProfile 5 0 R >>]";
let red = |catalog: &str| page_pdf("1 0 0 rg 0 0 40 40 re f", "", catalog, &[stream("/N 4", synthetic)]);
let p = plates(&red(intent), 0, 72.0, &Options::default()).unwrap();
let profile = craft_cms::synth::DESCRIPTION.to_string();
assert_eq!(p.conversion, Conversion::OutputIntent { profile: profile.clone(), condition: Some("Synthetic press".into()) });
let expected = red_in(synthetic);
assert!(near(red_tints(&p), expected), "{:?}, the output intent prints {expected:?}", red_tints(&p));
let fogra = plates(&red(""), 0, 72.0, &Options::default()).unwrap();
assert_eq!(fogra.conversion, Conversion::Fallback { profile: builtin::COATED_FOGRA39_DESCRIPTION.into() });
let expected = red_in(builtin::COATED_FOGRA39_ICC);
assert!(near(red_tints(&fogra), expected), "{:?}, FOGRA39 prints {expected:?}", red_tints(&fogra));
let callers = Options { fallback: Fallback::Profile(Arc::from(synthetic)), ..Options::default() };
assert_eq!(plates(&red(""), 0, 72.0, &callers).unwrap().conversion, Conversion::Fallback { profile });
let formulas = Options { fallback: Fallback::DeviceFormulas, ..Options::default() };
let device = plates(&red(""), 0, 72.0, &formulas).unwrap();
assert_eq!((device.conversion.clone(), red_tints(&device)), (Conversion::DeviceFormulas, [0, 255, 255, 0]));
let broken = Options { fallback: Fallback::Profile(Arc::from(&b"not a profile"[..])), ..Options::default() };
assert!(matches!(plates(&red(""), 0, 72.0, &broken), Err(Error::Profile(_))));
}
#[test]
fn spot_inks_past_the_cap_are_listed_not_separated_and_bad_input_is_refused() {
let n = MAX_SPOT_INKS + 2;
let spaces: String =
(0..n).map(|i| format!("/S{i} [/Separation /Spot{i} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] ")).collect();
let content: String = (0..n).map(|i| format!("/S{i} cs 1 scn {i} 0 1 1 re f ")).collect();
let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]);
let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
assert_eq!((p.inks.len(), p.planes.len()), (4 + MAX_SPOT_INKS, 4 + MAX_SPOT_INKS));
assert_eq!(p.unseparated, [format!("Spot{}", n - 2), format!("Spot{}", n - 1)]);
assert!(matches!(plates(&pdf, 1, 72.0, &Options::default()), Err(Error::NoPage(1))));
assert!(matches!(plates(&pdf, 0, f32::NAN, &Options::default()), Err(Error::InvalidDpi)));
assert!(matches!(plates(b"not a pdf", 0, 72.0, &Options::default()), Err(Error::Open(_))));
}
#[test]
fn escaped_spot_names_are_decoded_once_and_each_gets_its_own_plate() {
// `/A#2341` is the spot `A#41`, not `AA`; `/Spot#20#2312` is `Spot #12`.
let names = ["/A#2341", "/AA", "/Spot#20#2312"];
let spaces: String = names
.iter()
.enumerate()
.map(|(i, name)| format!("/S{i} [/Separation {name} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] "))
.collect();
let content: String = (0..names.len()).map(|i| format!("/S{i} cs 1 scn {} 0 10 10 re f ", i * 10)).collect();
let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]);
let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
let spots: Vec<&str> = p.inks.spots().map(|(_, s)| s.name.as_str()).collect();
assert_eq!(spots, ["A#41", "AA", "Spot #12"]);
assert!(p.unseparated.is_empty());
assert_eq!(p.planes.len(), 7);
for (i, (id, _)) in p.inks.spots().enumerate() {
for x in [5u32, 15, 25] {
let expected = if (x / 10) as usize == i { 255 } else { 0 };
assert_eq!(p.tint(id, x, 35), Some(expected), "spot {i} at x = {x}");
}
}
}
#[test]
fn lab_colors_convert_through_the_profile_and_lab_white_is_paper() {
let resources = "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] >>";
let pdf = page_pdf("/D50 cs 50 20 -10 scn 0 0 20 40 re f /D65 cs 100 0 0 scn 20 0 20 40 re f", resources, "", &[]);
let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
let at = |x| Process::ALL.map(|c| p.tint(InkId::process(c), x, 20).unwrap());
let fogra = Profile::parse(builtin::COATED_FOGRA39_ICC).unwrap();
let t = Transform::new(craft_cms::Builtin::LabD50.profile(), &fogra, Intent::RelativeColorimetric, false).unwrap();
let mut cmyk = [0.0_f32; 4];
t.convert_f32(&[0.5, 148.0 / 255.0, 118.0 / 255.0], 3, &mut cmyk, 4, false).unwrap();
let expected = cmyk.map(|v| (v * 255.0).round() as u8);
assert!(near(at(10), expected), "{:?}, FOGRA39 prints {expected:?}", at(10));
assert_eq!(at(30), [0, 0, 0, 0], "a Lab space's own white prints as paper");
}
/// A 60 × 10 pt page written by the PDF writer, with a 10 pt wide fill of each color.
fn written(colors: Vec<craft_pdf::write::backend::color::Color>) -> Vec<u8> {
use craft_pdf::write::backend::{geom, page::PageSettings, paint::Fill};
let mut writer = craft_pdf::write::Writer::new(Default::default(), Default::default());
let mut page = writer.document_mut().start_page_with(PageSettings::new(geom::Size::from_wh(60.0, 10.0).unwrap()));
let mut surface = page.surface();
for (i, color) in colors.into_iter().enumerate() {
let mut path = geom::PathBuilder::new();
path.push_rect(geom::Rect::from_xywh(10.0 * i as f32, 0.0, 10.0, 10.0).unwrap());
surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() }));
surface.draw_path(&path.finish().unwrap());
}
surface.finish();
page.finish();
writer.finish().unwrap()
}
/// The page's color space resources as (family, colorant names), sorted.
fn color_spaces(pdf: &[u8]) -> Vec<(String, Vec<String>)> {
use craft_pdf::syntax::object::{Array, Name, Object};
let pdf = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let page = pdf.pages().iter().next().unwrap();
let spaces = &page.resources().color_spaces;
let mut out: Vec<(String, Vec<String>)> = spaces
.keys()
.map(|key| {
let array = spaces.get::<Array<'_>>(key.as_ref()).unwrap();
let items: Vec<Object<'_>> = array.iter::<Object<'_>>().collect();
let Some(Object::Name(family)) = items.first() else { panic!("a color space without a family") };
let names = match items.get(1) {
Some(Object::Name(n)) => vec![n.as_str().to_owned()],
Some(Object::Array(a)) => a.iter::<Name<'_>>().map(|n| n.as_str().to_owned()).collect(),
_ => Vec::new(),
};
(family.as_str().to_owned(), names)
})
.collect();
out.sort();
out
}
#[test]
fn ink_colors_write_as_cmyk_separation_or_device_n_and_separate_onto_their_plates() {
use craft_prepress::{InkColor, InkSet, Spot, to_pdf_color};
let mut inks = InkSet::new();
let gold = inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap();
let teal = inks.add(Ink::Spot(Spot { name: "Teal".into(), alternate: Alternate::Lab([55.0, -40.0, -10.0]), solidity: None })).unwrap();
let (c, k) = (InkId::process(Process::Cyan), black());
let cms = CmsConversion::fogra39();
let colors = [
InkColor::Tints(vec![(k, 0.4), (c, 0.6)]),
InkColor::Tints(vec![(gold, 0.5)]),
InkColor::Tints(vec![(gold, 1.0), (k, 0.5)]),
InkColor::Tints(vec![(teal, 1.0)]),
InkColor::All(1.0),
InkColor::None,
];
let pdf = written(colors.iter().map(|color| to_pdf_color(color, &inks, &cms).unwrap()).collect());
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let mut expected = vec![
("DeviceN".to_owned(), names(&["Black", "Gold"])),
("Separation".to_owned(), names(&["All"])),
("Separation".to_owned(), names(&["Gold"])),
("Separation".to_owned(), names(&["None"])),
("Separation".to_owned(), names(&["Teal"])),
];
expected.sort();
assert_eq!(color_spaces(&pdf), expected, "process only is DeviceCMYK, which needs no resource");
let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect();
assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold", "Teal"]);
let (gold, teal) = (p.inks.lookup("Gold").unwrap(), p.inks.lookup("Teal").unwrap());
let at = |x: u32| -> Vec<u8> { p.inks.iter().map(|(id, _)| p.tint(id, x, 5).unwrap()).collect() };
assert_eq!(at(5), [153, 0, 0, 102, 0, 0]);
assert_eq!(at(15), [0, 0, 0, 0, 128, 0]);
assert_eq!(at(25), [0, 0, 0, 128, 255, 0]);
assert_eq!(at(35), [0, 0, 0, 0, 0, 255]);
assert_eq!(at(45), [255; 6], "registration");
assert_eq!(at(55), [0; 6]);
assert_eq!((p.tint(gold, 25, 5), p.tint(teal, 35, 5)), (Some(255), Some(255)));
let unknown = InkSet::new();
assert_eq!(to_pdf_color(&colors[1], &unknown, &cms), None, "an ink not in the set");
assert_eq!(to_pdf_color(&InkColor::Tints(vec![]), &inks, &cms), None, "no ink: nothing to paint");
let mut many = InkSet::new();
let tints: Vec<(InkId, f32)> = (0..33)
.map(|i| (many.add(Ink::Spot(Spot { name: format!("S{i}"), alternate: Alternate::Cmyk([0.0; 4]), solidity: None })).unwrap(), 1.0))
.collect();
assert_eq!(to_pdf_color(&InkColor::Tints(tints), &many, &cms), None, "more than 32 colorants");
}