Files
craft-libs/crates/pdf/tests/plates.rs
T
aiongg 4952238c66 hayro-interpret: port PdfCraft's /Default color spaces (#3018)
A resource dictionary's /DefaultGray, /DefaultRGB and /DefaultCMYK
replace the device spaces that color operators, cs/CS, images and
shadings select (ISO 32000-2 8.6.5.6). Decoded images and shadings are
cached per default.

Defaults follow this tree's resource scoping (a form with /Resources
sees only its own), and they do not apply on a plate, so device color
separates onto the same inks as before. PdfCraft's raster tests are
adapted in upstream_raster.rs, with a plate test in plates.rs.
2026-10-24 07:39:43 +00:00

540 lines
30 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.
//! Plate mode through the raster path: each ink's separation drawn as a gray film.
//! Adapted from PdfCraft #2770 by Guillem Hermida (`separations::tests` in pdfcraft-render at
//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0).
#![cfg(feature = "raster")]
#[path = "support/synthetic_icc.rs"]
mod synthetic_icc;
use craft_pdf::interpret::InterpreterSettings;
use craft_pdf::interpret::color::{CmykConversion, CmykSimulation, Ink, InkSinkFn, NoSimulation, PCS_WHITE, Plate};
use craft_pdf::{
open_slice,
raster::{self, Options, RenderCache},
};
use std::sync::{Arc, Mutex};
/// A 40 × 40 pt page with `content` and `resources`, and `objects` numbered from 5 (each a
/// whole object body: a dictionary, or a dictionary and its stream).
fn page_pdf(content: &str, resources: &str, objects: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
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
}
/// A stream object body with `dict` entries and `data`.
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
}
fn render(pdf: &[u8], settings: &InterpreterSettings) -> Vec<u8> {
render_each(pdf, std::slice::from_ref(settings)).remove(0)
}
/// The page rendered once per settings, sharing one render cache as a separating caller does.
fn render_each(pdf: &[u8], settings: &[InterpreterSettings]) -> Vec<Vec<u8>> {
let doc = open_slice(pdf, None).unwrap();
let cache = RenderCache::new();
let options = Options { background: [255; 4], ..Default::default() };
settings
.iter()
.map(|settings| {
let raster = raster::render(&doc.pages()[0], &cache, settings, &options).unwrap();
assert_eq!((raster.width, raster.height), (40, 40));
raster.bytes().to_vec()
})
.collect()
}
/// One film per ink, as RGBA8 over white.
struct Plates(Vec<Vec<u8>>);
impl Plates {
/// Each ink's tint at device pixel (x, y), in whole percent.
fn at(&self, x: usize, y: usize) -> Vec<u32> {
self.0
.iter()
.map(|film| {
let px = &film[(y * 40 + x) * 4..][..4];
assert_eq!((px[0], px[3]), (px[2], 255), "a film is opaque gray");
(f32::from(255 - px[0]) * 100.0 / 255.0).round() as u32
})
.collect()
}
}
fn plates(pdf: &[u8], inks: &[Ink], overprint: bool, simulation: Option<&CmykSimulation>) -> Plates {
let settings: Vec<InterpreterSettings> = inks
.iter()
.map(|ink| InterpreterSettings {
plate: Some(Plate { ink: ink.clone(), overprint, simulation: simulation.cloned(), spots_as_process: false }),
..Default::default()
})
.collect();
Plates(render_each(pdf, &settings))
}
fn spot(name: &str) -> Ink {
Ink::Spot(name.into())
}
/// The four process plates, then `spots`.
fn inks(spots: &[&str]) -> Vec<Ink> {
Ink::PROCESS.into_iter().chain(spots.iter().map(|s| spot(s))).collect()
}
/// The process plates and Gold, overprint honored.
fn separate(pdf: &[u8]) -> Plates {
plates(pdf, &inks(&["Gold"]), true, None)
}
/// 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 >>]";
fn gold_resource() -> String {
format!("/ColorSpace << /CS0 {GOLD} >>")
}
const CGATS: &[u8] = include_bytes!("../../../vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc");
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn device_cmyk_puts_each_component_on_its_own_plate() {
let s = plates(&page_pdf("0.2 0.4 0.6 0.8 k 0 0 40 40 re f", "", &[]), &inks(&[]), true, None);
assert_eq!(s.at(20, 20), [20, 40, 60, 80]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_spot_color_paints_its_own_plate_and_knocks_out_the_process_plates() {
let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /CS0 cs 0.5 scn 10 10 20 20 re f", &gold_resource(), &[]));
assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "knocked out under the spot color");
assert_eq!(s.at(2, 2), [0, 0, 0, 100, 0]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn overprinting_keeps_the_plates_a_color_does_not_specify() {
let resources = format!("{} /ExtGState << /GS1 << /op true >> >>", gold_resource());
let pdf = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS1 gs /CS0 cs 0.5 scn 10 10 20 20 re f", &resources, &[]);
assert_eq!(separate(&pdf).at(20, 20), [0, 0, 0, 100, 50], "overprinted");
assert_eq!(plates(&pdf, &inks(&["Gold"]), false, None).at(20, 20), [0, 0, 0, 0, 50], "overprint simulation off: knocked out");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn op_sets_stroke_overprint_and_fill_overprint_too_without_op() {
let resources = format!("{} /ExtGState << /GS1 << /OP true >> /GS2 << /OP true /op false >> >>", gold_resource());
let fill = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f /{gs} gs /CS0 cs 1 scn 10 10 20 20 re f"), &resources, &[]);
assert_eq!(separate(&fill("GS1")).at(20, 20)[3], 100, "OP alone also sets op");
assert_eq!(separate(&fill("GS2")).at(20, 20)[3], 0, "op false keeps fills knocking out");
let stroke = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS2 gs /CS0 CS 1 SCN 8 w 0 20 m 40 20 l S", &resources, &[]);
assert_eq!(separate(&stroke).at(20, 20), [0, 0, 0, 100, 100], "strokes overprint with OP");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn nonzero_overprint_mode_leaves_zero_device_cmyk_components() {
let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> /M0 << /OP true /OPM 0 >> >>";
let pdf = |gs: &str| page_pdf(&format!("1 0 0 0 k 0 0 40 40 re f /{gs} gs 0 1 0 0 k 10 10 20 20 re f"), resources, &[]);
let process = inks(&[]);
assert_eq!(plates(&pdf("M1"), &process, true, None).at(20, 20), [100, 100, 0, 0], "OPM 1: the zero cyan leaves the plate");
assert_eq!(plates(&pdf("M0"), &process, true, None).at(20, 20), [0, 100, 0, 0], "OPM 0: zero components paint");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn gray_and_rgb_are_process_color_and_never_take_the_nonzero_overprint_mode() {
let process = inks(&[]);
let gray = page_pdf("1 0 0 0 k 0 0 40 40 re f /M1 gs 0.25 g 10 10 20 20 re f", "/ExtGState << /M1 << /OP true /OPM 1 >> >>", &[]);
assert_eq!(plates(&gray, &process, true, None).at(20, 20), [0, 0, 0, 75], "gray is black only, and knocks out cyan");
let s = plates(&page_pdf("1 0 0 rg 0 0 20 40 re f 0.2 0.2 0.2 rg 20 0 20 40 re f", "", &[]), &process, true, None);
assert_eq!(s.at(10, 20), [0, 100, 100, 0], "red");
assert_eq!(s.at(30, 20), [0, 0, 0, 80], "a neutral gray goes to black (10.4.2.4)");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn devicen_components_go_to_the_plates_they_name() {
let resources = "/ColorSpace << /CS0 [/DeviceN [/Cyan /Silver /None] /DeviceCMYK 5 0 R] >>";
let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ pop 0.5 mul 0 0 }");
let s = plates(&page_pdf("/CS0 cs 0.3 0.7 1 scn 0 0 40 40 re f", resources, &[function]), &inks(&["Silver"]), true, None);
assert_eq!(s.at(20, 20), [30, 0, 0, 0, 70]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn the_all_colorant_paints_every_plate_and_none_paints_nothing() {
let gray = "/DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
let resources = format!("/ColorSpace << /CS0 {GOLD} /Reg [/Separation /All {gray}] /No [/Separation /None {gray}] >>");
let content = "/CS0 cs 0.2 scn 0 0 40 40 re f /Reg cs 1 scn 0 0 20 40 re f /No cs 1 scn 20 0 20 40 re f";
let s = separate(&page_pdf(content, &resources, &[]));
assert_eq!(s.at(10, 20), [100, 100, 100, 100, 100], "registration color");
assert_eq!(s.at(30, 20), [0, 0, 0, 0, 20], "None leaves every plate as it is");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn icc_based_cmyk_is_separated_as_its_components() {
let pdf = page_pdf("/CS0 cs 0.1 0.3 0.5 0.7 scn 0 0 40 40 re f", "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", &[stream("/N 4", CGATS)]);
assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [10, 30, 50, 70]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn images_are_separated_sample_by_sample() {
let cmyk = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[255, 0, 0, 0, 0, 0, 0, 128]);
let s = plates(&page_pdf("q 40 0 0 40 0 0 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &[cmyk]), &inks(&[]), true, None);
assert_eq!(s.at(10, 20), [100, 0, 0, 0]);
assert_eq!(s.at(30, 20), [0, 0, 0, 50]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_spot_color_image_overprints_like_any_paint() {
let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[128]);
let resources = "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true >> >>";
let pdf = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} q 20 0 0 20 10 10 cm /Im1 Do Q"), resources, std::slice::from_ref(&image));
assert_eq!(separate(&pdf("/GS1 gs")).at(20, 20), [0, 0, 0, 100, 50]);
assert_eq!(separate(&pdf("")).at(20, 20), [0, 0, 0, 0, 50]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn shadings_are_separated_through_their_color_space() {
let shading = format!(
"<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 40 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>"
);
let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /Sh1 sh", "/Shading << /Sh1 5 0 R >>", &[shading.into_bytes()]));
let (left, right) = (s.at(4, 20), s.at(36, 20));
assert!(left[4] < 20 && right[4] > 80, "a gradient on the spot plate: {left:?} {right:?}");
assert_eq!((left[3], right[3]), (0, 0), "the shading knocks out the black under it");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn an_uncolored_pattern_paints_the_film_of_the_color_it_is_used_with() {
let cell = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", b"0 0 10 10 re f");
let resources = format!("/ColorSpace << /PCS [/Pattern {GOLD}] >> /Pattern << /P1 5 0 R >>");
let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /PCS cs 0.5 /P1 scn 0 0 40 40 re f", &resources, &[cell]));
assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "the pattern's spot color, knocking out black");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn soft_masks_keep_the_luminosity_of_their_own_colors() {
// A mask drawn in blue lets about a tenth of the black through. Drawn as the black
// plate's film, blue (no black ink) would read as white and let all of it through.
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceRGB >>", b"0 0 1 rg 0 0 40 40 re f");
let resources = "/ExtGState << /SM << /SMask << /S /Luminosity /G 5 0 R >> >> >>";
let s = plates(&page_pdf("/SM gs 0 0 0 1 k 0 0 40 40 re f", resources, &[group]), &[Ink::Black], true, None);
let black = s.at(20, 20)[0];
assert!((1..30).contains(&black), "black through a blue luminosity mask: {black}%");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_device_cmyk_luminosity_mask_reads_its_inks_by_the_device_formula() {
// Black ink through a mask whose DeviceCMYK group paints the left half, at (10, 20), and
// leaves the backdrop on the right, at (30, 20). Luminosity is 1 - min(1, 0.3c + 0.59m +
// 0.11y + k) (ISO 32000-2 §10.4.2.2); Gold's alternate is C0 M30 Y100 K0.
let masked = |paint: &str, backdrop: &str| {
let group = stream(
&format!(
"/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceCMYK >> /Resources << {} >>",
gold_resource()
),
format!("{paint} 0 0 20 40 re f").as_bytes(),
);
let resources = format!("/ExtGState << /SM << /SMask << /S /Luminosity /G 5 0 R {backdrop} >> >> >>");
let s = plates(&page_pdf("/SM gs 0 0 0 1 k 0 0 40 40 re f", &resources, &[group]), &[Ink::Black], true, None);
[s.at(10, 20)[0], s.at(30, 20)[0]]
};
assert_eq!(masked("0 0 0 1 k", ""), [0, 0], "K 100% and the default black backdrop hide all of it");
assert_eq!(masked("0 0 0 0 k", "/BC [0 0 0 0]"), [100, 100], "paper shows all of it");
assert_eq!(masked("0.5 0 0 0 k", "/BC [0 0 0 0.25]"), [85, 75]);
assert_eq!(masked("/CS0 cs 1 scn", ""), [71, 0], "a spot ink reads as its alternate CMYK");
assert_eq!(masked("1 0 0 rg", ""), [30, 0], "RGB red is C0 M100 Y100 K0 by the device formulas");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn the_ink_sink_reports_spot_inks_of_patterns_and_images_but_not_of_soft_masks() {
let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[255]);
let cell = stream(
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << /ColorSpace << /S [/Separation /Silver /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0.5] /N 1 >>] >> >>",
b"/S cs 1 scn 0 0 5 5 re f",
);
let mask_group = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /ColorSpace << /M [/Separation /MaskOnly /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>",
b"/M cs 0.5 scn 0 0 40 40 re f",
);
let resources = "/XObject << /Im1 5 0 R >> /Pattern << /P1 6 0 R >> /ExtGState << /SM << /SMask << /S /Luminosity /G 7 0 R >> >> >>";
let content = "/Pattern cs /P1 scn 0 0 20 20 re f q 10 0 0 10 25 25 cm /Im1 Do Q /SM gs 0 g 0 0 40 40 re f";
// Each spot ink with its appearance at 100%.
type Seen = Vec<(Ink, [u8; 3])>;
let seen: Arc<Mutex<Seen>> = Arc::default();
let log = seen.clone();
let sink: InkSinkFn = Arc::new(move |ink: &Ink, appearance: &dyn Fn(f32) -> [u8; 3]| {
let mut log = log.lock().unwrap();
if !log.iter().any(|(seen, _)| seen == ink) {
log.push((ink.clone(), appearance(1.0)));
}
});
render(&page_pdf(content, resources, &[image, cell, mask_group]), &InterpreterSettings { ink_sink: Some(sink), ..Default::default() });
let seen = seen.lock().unwrap();
let names: Vec<&str> = seen.iter().map(|(ink, _)| ink.name()).collect();
assert_eq!(names, ["Silver", "Gold"], "in the order first painted; a mask's ink doesn't print");
assert_eq!(seen[0].1, [128, 128, 128], "Silver at 100% is its alternate color");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn malformed_color_spaces_and_overprint_parameters_draw_what_they_can() {
let resources = "/ColorSpace << /A [/DeviceN [] /DeviceGray 0] /B [/Separation] /C [/Indexed /DeviceCMYK 300 <00>] >> /ExtGState << /G << /OP 7 /OPM /X /op (y) >> >>";
let pdf = page_pdf("/G gs /A cs 1 scn 0 0 9 9 re f /B cs 1 scn /C cs 255 scn 0 0 40 40 re f", resources, &[]);
plates(&pdf, &inks(&["Gold"]), true, None);
}
// Separating through an output intent's CMYK profile (vendored patch 36).
const PRESS: &str = "PdfCraft synthetic press";
fn press_simulation() -> CmykSimulation {
CmykSimulation::new(&synthetic_icc::cmyk_profile(PRESS, true)).unwrap()
}
/// What the synthetic press prints for CIE L*a*b*, in whole percent.
fn press(l: f64, a: f64, b: f64) -> Vec<u32> {
synthetic_icc::btoa(l, a, b).iter().map(|v| (v * 100.0).round() as u32).collect()
}
/// Whether every ink is within `tolerance` percent of `expected`.
fn near(got: &[u32], expected: &[u32], tolerance: u32) -> bool {
got.len() == expected.len() && got.iter().zip(expected).all(|(g, e)| g.abs_diff(*e) <= tolerance)
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn rgb_separates_through_the_simulated_cmyk_profile() {
let simulation = press_simulation();
assert_eq!(simulation.description(), Some(PRESS));
assert!(!simulation.perceptual());
let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]);
// The press prints a neutral with equal inks, where the device formulas put it on black
// alone.
let gray = press(synthetic_icc::srgb_gray_lightness(0.5), 0.0, 0.0);
let got = plates(&pdf, &inks(&[]), true, Some(&simulation)).at(20, 20);
assert!(near(&got, &gray, 2), "{got:?}, the press prints {gray:?}");
assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [0, 0, 0, 50]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn only_cmyk_profiles_with_pcs_to_device_tables_simulate() {
let whole = synthetic_icc::cmyk_profile(PRESS, true);
assert_eq!(CmykSimulation::new(&synthetic_icc::gray_profile("Gray")).err(), Some(NoSimulation::NotCmyk));
assert_eq!(CmykSimulation::new(&synthetic_icc::cmyk_profile("Proofing only", false)).err(), Some(NoSimulation::NoPcsToDevice));
assert_eq!(CmykSimulation::new(b"not a profile").err(), Some(NoSimulation::Unreadable));
assert_eq!(CmykSimulation::new(&whole[..300]).err(), Some(NoSimulation::Unreadable));
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn cmyk_in_the_simulated_profile_passes_through_and_other_cmyk_converts() {
let profile = synthetic_icc::cmyk_profile(PRESS, true);
let simulation = CmykSimulation::new(&profile).unwrap();
// Object 5 is the press profile, 6 another CMYK profile.
let resources = "/ColorSpace << /Same [/ICCBased 5 0 R] /Other [/ICCBased 6 0 R] >>";
let content = "0.2 0.4 0.6 0.8 k 0 0 10 40 re f /Same cs 0.1 0.3 0.5 0.7 scn 10 0 10 40 re f /Other cs 0 0 0 1 scn 30 0 10 40 re f";
let pdf = page_pdf(content, resources, &[stream("/N 4", &profile), stream("/N 4", CGATS)]);
let s = plates(&pdf, &inks(&[]), true, Some(&simulation));
assert_eq!(s.at(5, 20), [20, 40, 60, 80], "DeviceCMYK is the press's own CMYK");
assert_eq!(s.at(15, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile");
let other = s.at(35, 20);
assert!(other.iter().all(|v| *v > 70), "black alone in another profile is a four-ink black on this press: {other:?}");
assert_eq!(plates(&pdf, &inks(&[]), true, None).at(35, 20), [0, 0, 0, 100], "without a simulation ICC CMYK is taken as it is");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn device_gray_stays_on_the_black_plate_under_a_simulation() {
let s = plates(&page_pdf("0.25 g 0 0 40 40 re f", "", &[]), &inks(&[]), true, Some(&press_simulation()));
assert_eq!(s.at(20, 20), [0, 0, 0, 75]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn cie_and_icc_colors_convert_through_the_profile_relative_to_their_white() {
let resources =
"/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Gray [/ICCBased 5 0 R] >>";
let content = "/D50 cs 50 20 -10 scn 0 0 10 40 re f /D65 cs 100 0 0 scn 10 0 10 40 re f /Gray cs 0.5 scn 20 0 10 40 re f";
let pdf = page_pdf(content, resources, &[stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2"))]);
let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation()));
assert!(near(&s.at(5, 20), &press(50.0, 20.0, -10.0), 2), "Lab: {:?}", s.at(5, 20));
assert_eq!(s.at(15, 20), [0, 0, 0, 0], "a Lab space's own white prints as paper, whatever its white point");
let lightness = 116.0 * 0.5_f64.powf(2.2).cbrt() - 16.0;
assert!(near(&s.at(25, 20), &press(lightness, 0.0, 0.0), 2), "ICC gray: {:?}", s.at(25, 20));
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn images_convert_through_the_simulation_sample_by_sample() {
let rgb = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB", &[128, 128, 128, 255, 255, 255]);
// More samples than palette entries: the palette is separated once.
let indexed = stream(
"/Type /XObject /Subtype /Image /Width 4 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 <808080FFFFFF>]",
&[0, 0, 1, 1],
);
let content = "q 40 0 0 20 0 20 cm /Im1 Do Q q 40 0 0 20 0 0 cm /Im2 Do Q";
let pdf = page_pdf(content, "/XObject << /Im1 5 0 R /Im2 6 0 R >>", &[rgb, indexed]);
let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation()));
let gray = press(synthetic_icc::srgb_gray_lightness(128.0 / 255.0), 0.0, 0.0);
for y in [10, 30] {
assert!(near(&s.at(10, y), &gray, 2), "y = {y}: {:?}, the press prints {gray:?}", s.at(10, y));
assert_eq!(s.at(30, y), [0, 0, 0, 0], "y = {y}: white prints no ink");
}
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_simulation_shows_its_cmyk_through_its_own_profile() {
// The inks the press prints sRGB gray 128 with show as that gray: its two tables invert
// each other.
let ink = (255.0 * (1.0 - synthetic_icc::srgb_gray_lightness(128.0 / 255.0) / 100.0)).round() as u8;
let mut shown = [0; 3];
press_simulation().cmyk_to_srgb8(&[ink; 4], &mut shown).unwrap();
assert!(shown.iter().all(|c| c.abs_diff(128) <= 4), "{shown:?}");
assert!(press_simulation().cmyk_to_srgb8(&[0; 4], &mut [0; 4]).is_none(), "lengths disagree");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn mutated_profiles_simulate_or_are_refused_without_panicking() {
// The header fields a reader checks, every 16th byte of the tag table and bytes spread
// over the tag data, changed in turn. Making a simulation takes a fifth of a second in a
// debug build, so header bytes readers skip (date, creator, profile ID, reserved) are left
// out and the data is sampled.
let whole = synthetic_icc::cmyk_profile(PRESS, true);
let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]);
// Size, version, class, color space, PCS, `acsp`, rendering intent, illuminant.
let header = [3, 8, 12, 16, 20, 36, 67, 70];
let positions = header.into_iter().chain((128..256).step_by(16)).chain((256..whole.len()).step_by(15_000));
for (n, at_byte) in positions.enumerate() {
let mut bent = whole.clone();
bent[at_byte] = [0x00, 0x7F, 0xFF][n % 3];
if let Ok(simulation) = CmykSimulation::new(&bent) {
let plate = Plate { ink: Ink::Cyan, overprint: true, simulation: Some(simulation), spots_as_process: false };
render(&pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() });
}
}
}
/// A caller's conversion: sRGB prints as 10/20/30/40%, XYZ as 50/60/70/80%, and the XYZ it is
/// given is kept.
struct Fixed(Arc<Mutex<Vec<[f32; 3]>>>);
impl CmykConversion for Fixed {
fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> {
(rgb.len() / 3 == cmyk.len() / 4).then(|| cmyk.as_chunks_mut::<4>().0.fill([0.1, 0.2, 0.3, 0.4]))
}
fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> {
self.0.lock().unwrap().extend(xyz.as_chunks::<3>().0.iter().copied());
cmyk.as_chunks_mut::<4>().0.fill([0.5, 0.6, 0.7, 0.8]);
Some(())
}
fn description(&self) -> Option<&str> {
Some("fixed")
}
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_callers_cmyk_conversion_separates_process_color() {
let profile = synthetic_icc::cmyk_profile(PRESS, true);
let xyz = Arc::default();
let simulation = CmykSimulation::with_conversion(&profile, Fixed(Arc::clone(&xyz)));
assert_eq!(simulation.description(), Some("fixed"));
assert!(simulation.cmyk_to_srgb8(&[0; 4], &mut [0; 3]).is_none(), "no way to show CMYK");
let resources = "/ColorSpace << /Lab [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Same [/ICCBased 5 0 R] >>";
let content = "1 0 0 rg 0 0 10 40 re f /Lab cs 100 0 0 scn 10 0 10 40 re f 0.2 0.4 0.6 0.8 k 20 0 10 40 re f \
/Same cs 0.1 0.3 0.5 0.7 scn 30 0 10 40 re f";
let s = plates(&page_pdf(content, resources, &[stream("/N 4", &profile)]), &inks(&[]), true, Some(&simulation));
assert_eq!(s.at(5, 20), [10, 20, 30, 40], "DeviceRGB through from_srgb");
assert_eq!(s.at(15, 20), [50, 60, 70, 80], "Lab through from_pcs_xyz");
assert_eq!(s.at(25, 20), [20, 40, 60, 80], "DeviceCMYK as it is");
assert_eq!(s.at(35, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile as it is");
let xyz = xyz.lock().unwrap();
assert!(!xyz.is_empty());
for c in xyz.iter() {
assert!(c.iter().zip(PCS_WHITE).all(|(v, w)| (v - w).abs() < 1e-3), "a Lab white arrives as the PCS white: {c:?}");
}
}
/// A knockout group (`/K true`, pdfcraft #2974) of two process squares at 50 % opacity separates
/// with only the top square where they overlap: its plates replace the bottom square's there.
/// Without `/K` the two blend on every plate.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_knockout_group_separates_with_only_the_top_object_in_the_overlap() {
let group = |entries: &str| {
let form = "/A gs 1 0 0 0 k 0 0 25 25 re f 0 1 0 0 k 15 15 25 25 re f";
page_pdf(
"/Fm Do",
"/XObject << /Fm 5 0 R >>",
&[stream(
&format!(
"/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency {entries} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >>"
),
form.as_bytes(),
)],
)
};
// Device rows run top down: the overlap is at (20, 20), cyan alone at (5, 35), magenta alone
// at (35, 5).
let knockout = plates(&group("/K true"), &inks(&[]), true, None);
assert_eq!(knockout.at(20, 20), [0, 50, 0, 0], "only the magenta square in the overlap");
assert_eq!(knockout.at(5, 35), [50, 0, 0, 0]);
assert_eq!(knockout.at(35, 5), [0, 50, 0, 0]);
let blended = plates(&group(""), &inks(&[]), true, None);
assert_eq!(blended.at(20, 20), [25, 50, 0, 0], "both squares without /K");
}
/// PdfCraft #3018: `/DefaultCMYK` and `/DefaultGray` change how device color looks, not the
/// inks it prints with. Under them DeviceCMYK still separates as its components (and keeps the
/// nonzero overprint mode), a DeviceCMYK image sample by sample, and DeviceGray on black alone,
/// with or without a simulation whose profile differs from the default.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn default_color_spaces_leave_device_color_on_its_plates() {
let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[51, 102, 153, 204]);
let objects = [stream("/N 4", CGATS), stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2")), image];
let content = "0 0 0 1 k 0 0 10 40 re f 1 0 0 0 k 10 0 10 40 re f /M1 gs 0 1 0 0 k 10 0 10 40 re f \
q 10 0 0 40 20 0 cm /Im1 Do Q 0.25 g 30 0 10 40 re f";
let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> >> /XObject << /Im1 7 0 R >>";
let calibrated = format!("{resources} /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >>");
let expected: [[u32; 4]; 4] = [[0, 0, 0, 100], [100, 100, 0, 0], [20, 40, 60, 80], [0, 0, 0, 75]];
for simulation in [None, Some(press_simulation())] {
let s = plates(&page_pdf(content, &calibrated, &objects), &inks(&[]), true, simulation.as_ref());
for (x, want) in [5, 15, 25, 35].into_iter().zip(expected) {
assert_eq!(s.at(x, 20), want, "x {x}, simulated: {}", simulation.is_some());
}
}
}