diff --git a/NOTICE b/NOTICE index d9e06ba0..29578b98 100644 --- a/NOTICE +++ b/NOTICE @@ -101,6 +101,10 @@ Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 These patched sources are transferred from PdfCraft revision ec0a965074f376a5bf610435948e4e5184bf3299 (https://github.com/storytold/pdfcraft). See vendor/README.craft-reader.md and each crate's SOURCE.json for provenance and local changes. +The hayro-interpret plate mode (separations, overprint and output-intent simulation) is ported +from PdfCraft pull request #2770 by Guillem Hermida, commit +6fcf2a4aca29e943e28cb69017ff8f87d00656c6 (https://github.com/storytold/pdfcraft/pull/2770), +MIT OR Apache-2.0; crates/pdf/tests/plates.rs and tests/support/synthetic_icc.rs adapt its tests. The reader retains the existing Foxit/PDFium standard-14 font assets (BSD-3-Clause), Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Clause). Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs new file mode 100644 index 00000000..8588e3cf --- /dev/null +++ b/crates/pdf/tests/plates.rs @@ -0,0 +1,448 @@ +//! 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]) -> Vec { + 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 { + 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 { + let doc = open_slice(pdf, None).unwrap(); + let options = Options { background: [255; 4], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), settings, &options).unwrap(); + assert_eq!((raster.width, raster.height), (40, 40)); + raster.bytes().to_vec() +} + +/// One film per ink, as RGBA8 over white. +struct Plates(Vec>); + +impl Plates { + /// Each ink's tint at device pixel (x, y), in whole percent. + fn at(&self, x: usize, y: usize) -> Vec { + 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 { + Plates( + inks.iter() + .map(|ink| { + let plate = Plate { ink: ink.clone(), overprint, simulation: simulation.cloned() }; + render(pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() }) + }) + .collect(), + ) +} + +fn spot(name: &str) -> Ink { + Ink::Spot(name.into()) +} + +/// The four process plates, then `spots`. +fn inks(spots: &[&str]) -> Vec { + Ink::PROCESS.into_iter().chain(spots.iter().map(|s| spot(s))).collect() +} + +/// The process plates and Gold, overprint honoured. +fn separate(pdf: &[u8]) -> Plates { + plates(pdf, &inks(&["Gold"]), true, None) +} + +/// A spot colour 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_colour_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 colour"); + 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_colour_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_colour_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 colour"); + 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_colour_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_colour_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_uncoloured_pattern_paints_the_film_of_the_colour_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 colour, 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_colours() { + // 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 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> = 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 colour"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn malformed_colour_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 { + 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_colours_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() { + // Bytes through the profile's header, tag table and tables changed in turn. Making a + // simulation takes a third of a second in a debug build, so the bytes are 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", "", &[]); + let positions = (0..whole.len().min(256)).step_by(16).chain((256..whole.len()).step_by(9973)); + 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) }; + 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>>); + +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_colour() { + 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:?}"); + } +} diff --git a/crates/pdf/tests/support/synthetic_icc.rs b/crates/pdf/tests/support/synthetic_icc.rs new file mode 100644 index 00000000..f81ceca0 --- /dev/null +++ b/crates/pdf/tests/support/synthetic_icc.rs @@ -0,0 +1,227 @@ +//! Synthetic ICC profiles for the plate-mode tests, written byte by byte from ICC.1 (version +//! 2.1 profiles with `lut16Type` tables and the legacy 16-bit PCS L*a*b* encoding). They are +//! contributor-original and generated when a test runs; no profile file is committed. +//! +//! The CMYK printing condition is made up so that its conversions can be worked out by hand: +//! +//! - AToB (CMYK → PCS L*a*b*): L* = 100 · (1 − (c + m + y + k) / 4), a* = 60 · (m − c), +//! b* = 60 · (y − (c + m) / 2). It is affine, so a two-point grid holds it exactly. +//! - BToA (PCS → CMYK), its inverse with equal inks along the neutral axis: with +//! t = 1 − L* / 100, c = t − a* / 120, m = t + a* / 120, y = t + b* / 60, k = t − b* / 60, each +//! clamped to 0..1. A neutral of L* 50 prints as 50% of each ink, where the ISO 32000-2 +//! §10.4.2.4 device formulas would print it on black alone. +//! +//! From PdfCraft #2770 by Guillem Hermida (`crates/render/tests/support/synthetic_icc.rs` at +//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0). Used by `tests/plates.rs` +//! through `#[path]`. + +#![allow(dead_code)] + +/// Grid points per input of the BToA table: fine enough that colours a little off the neutral +/// axis interpolate between unclamped grid points. +const BTOA_GRID: usize = 17; + +/// What the synthetic printing condition prints for PCS L*a*b*, each ink `0.0..=1.0`. +pub fn btoa(l: f64, a: f64, b: f64) -> [f64; 4] { + let t = 1.0 - l / 100.0; + [t - a / 120.0, t + a / 120.0, t + b / 60.0, t - b / 60.0].map(|v| v.clamp(0.0, 1.0)) +} + +/// The PCS L*a*b* the synthetic printing condition gives an ink combination. +pub fn atob([c, m, y, k]: [f64; 4]) -> [f64; 3] { + [100.0 * (1.0 - (c + m + y + k) / 4.0), 60.0 * (m - c), 60.0 * (y - (c + m) / 2.0)] +} + +/// The CIE L* of an sRGB gray `v` (`0.0..=1.0`); a neutral's a* and b* are 0. +pub fn srgb_gray_lightness(v: f64) -> f64 { + let linear = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let f = if linear > 216.0 / 24389.0 { linear.cbrt() } else { (24389.0 / 27.0 * linear + 16.0) / 116.0 }; + 116.0 * f - 16.0 +} + +/// A CMYK output profile (`prtr`) of the synthetic printing condition, described as +/// `description`. With `btoa` false it has no PCS-to-device tables (and so can't be printed to). +pub fn cmyk_profile(description: &str, btoa_tables: bool) -> Vec { + let atob_table = lut16(4, 3, 2, false, |node| { + let cmyk = [node[0], node[1], node[2], node[3]]; + encode_lab(atob(cmyk)) + }); + let mut tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"desc", text_description(description)), + (b"cprt", text(b"No copyright, synthetic test data")), + (b"wtpt", xyz(D50)), + (b"A2B0", atob_table.clone()), + (b"A2B1", atob_table.clone()), + (b"A2B2", atob_table), + ]; + if btoa_tables { + let btoa_table = lut16(3, 4, BTOA_GRID, true, |node| { + let [l, a, b] = grid_lab([node[0], node[1], node[2]]); + btoa(l, a, b).map(|v| v * 65535.0).to_vec() + }); + tags.push((b"B2A0", btoa_table.clone())); + tags.push((b"B2A1", btoa_table.clone())); + tags.push((b"B2A2", btoa_table)); + // Everything is in gamut. + tags.push((b"gamt", lut16(3, 1, 2, false, |_| vec![0.0]))); + } + profile(b"prtr", b"CMYK", b"Lab ", &tags) +} + +/// A gray display profile (gamma 2.2), described as `description`: an output intent that is +/// not CMYK. +pub fn gray_profile(description: &str) -> Vec { + // curveType with one entry: a gamma as u8Fixed8Number (2.2 ≈ 0x0233). + let mut curve = b"curv\0\0\0\0".to_vec(); + curve.extend_from_slice(&1_u32.to_be_bytes()); + curve.extend_from_slice(&0x0233_u16.to_be_bytes()); + let tags: Vec<(&[u8; 4], Vec)> = + vec![(b"desc", text_description(description)), (b"cprt", text(b"No copyright, synthetic test data")), (b"wtpt", xyz(D50)), (b"kTRC", curve)]; + profile(b"mntr", b"GRAY", b"XYZ ", &tags) +} + +/// The D50 illuminant of the PCS. +const D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// PCS L*a*b* in the legacy 16-bit encoding (ICC.1 Annex A), as `0.0..=65535.0` table values: +/// L* 100 is 0xFF00 and a* or b* 0 is 0x8000. +fn encode_lab([l, a, b]: [f64; 3]) -> Vec { + vec![l * 65280.0 / 100.0, (a + 128.0) * 256.0, (b + 128.0) * 256.0].into_iter().map(|v| v.clamp(0.0, 65535.0)).collect() +} + +/// A BToA grid point's PCS L*a*b*: `node` holds its inputs as `0.0..=1.0`. The L* input +/// table has stretched L* 100 (0xFF00) onto the last grid point, so white prints no ink; a* +/// and b* are in the legacy encoding. +fn grid_lab(node: [f64; 3]) -> [f64; 3] { + let [l, a, b] = node; + [l * 100.0, a * 65535.0 / 256.0 - 128.0, b * 65535.0 / 256.0 - 128.0] +} + +/// A `lut16Type` with `inputs` × `outputs` channels, `grid` points per input, identity +/// matrix and output tables, and the grid filled by `value` (each grid point's inputs as +/// `0.0..=1.0`, returning its outputs as `0.0..=65535.0`). The input tables are the identity, +/// except that `stretch_lightness` scales the first input by 0xFFFF / 0xFF00 (see [`grid_lab`]). +fn lut16(inputs: usize, outputs: usize, grid: usize, stretch_lightness: bool, value: impl Fn(&[f64]) -> Vec) -> Vec { + const ENTRIES: usize = 257; + let mut out = b"mft2\0\0\0\0".to_vec(); + out.extend_from_slice(&[inputs as u8, outputs as u8, grid as u8, 0]); + for k in 0_usize..9 { + let one = if k.is_multiple_of(4) { 0x0001_0000_i32 } else { 0 }; + out.extend_from_slice(&one.to_be_bytes()); + } + out.extend_from_slice(&(ENTRIES as u16).to_be_bytes()); + out.extend_from_slice(&2_u16.to_be_bytes()); + for channel in 0..inputs { + for e in 0..ENTRIES { + let x = e as f64 / (ENTRIES - 1) as f64; + let y = if stretch_lightness && channel == 0 { (x * 65535.0 / 65280.0).min(1.0) } else { x }; + out.extend_from_slice(&((y * 65535.0).round() as u16).to_be_bytes()); + } + } + // The first input varies slowest. + let points = grid.pow(inputs as u32); + for index in 0..points { + let mut node = vec![0.0; inputs]; + let mut rest = index; + for i in (0..inputs).rev() { + node[i] = (rest % grid) as f64 / (grid - 1) as f64; + rest /= grid; + } + let values = value(&node); + assert_eq!(values.len(), outputs); + for v in values { + out.extend_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_be_bytes()); + } + } + for _ in 0..outputs { + out.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + out +} + +/// A `textDescriptionType` (ICC.1:2001, the version 2 `desc` tag) with an ASCII description. +fn text_description(description: &str) -> Vec { + let ascii: Vec = description.bytes().filter(u8::is_ascii).collect(); + let mut out = b"desc\0\0\0\0".to_vec(); + out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes()); + out.extend_from_slice(&ascii); + out.push(0); + out.extend_from_slice(&[0; 4]); // Unicode language code + out.extend_from_slice(&[0; 4]); // Unicode count + out.extend_from_slice(&[0; 2]); // ScriptCode code + out.push(0); // ScriptCode count + out.extend_from_slice(&[0; 67]); + out +} + +/// A `textType`. +fn text(ascii: &[u8]) -> Vec { + let mut out = b"text\0\0\0\0".to_vec(); + out.extend_from_slice(ascii); + out.push(0); + out +} + +fn s15_fixed16(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +/// An `XYZType` with one value. +fn xyz(v: [f64; 3]) -> Vec { + let mut out = b"XYZ \0\0\0\0".to_vec(); + for c in v { + out.extend_from_slice(&s15_fixed16(c)); + } + out +} + +/// A whole version 2.1 profile: the 128-byte header, the tag table, then each tag's data, +/// 4-byte aligned. Tags with equal data share it. +fn profile(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[(&[u8; 4], Vec)]) -> Vec { + let table_end = 128 + 4 + 12 * tags.len(); + let mut data: Vec = Vec::new(); + let mut entries: Vec<(&[u8; 4], usize, usize)> = Vec::new(); + for (signature, body) in tags { + let shared = entries.iter().zip(tags).find(|(_, (_, b))| b == body).map(|((_, offset, len), _)| (*offset, *len)); + let (offset, len) = match shared { + Some(place) => place, + None => { + while !data.len().is_multiple_of(4) { + data.push(0); + } + let offset = table_end + data.len(); + data.extend_from_slice(body); + (offset, body.len()) + } + }; + entries.push((signature, offset, len)); + } + while !data.len().is_multiple_of(4) { + data.push(0); + } + let size = table_end + data.len(); + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&(size as u32).to_be_bytes()); + out.extend_from_slice(&[0; 4]); // preferred CMM + out.extend_from_slice(&0x0210_0000_u32.to_be_bytes()); + out.extend_from_slice(class); + out.extend_from_slice(space); + out.extend_from_slice(pcs); + for v in [2026_u16, 10, 16, 0, 0, 0] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.extend_from_slice(b"acsp"); + out.extend_from_slice(&[0; 4 + 4 + 4 + 4 + 8 + 4]); // platform, flags, manufacturer, model, attributes, intent + for c in D50 { + out.extend_from_slice(&s15_fixed16(c)); + } + out.extend_from_slice(&[0; 4 + 16 + 28]); // creator, ID, reserved + out.extend_from_slice(&(tags.len() as u32).to_be_bytes()); + for (signature, offset, len) in &entries { + out.extend_from_slice(*signature); + out.extend_from_slice(&(*offset as u32).to_be_bytes()); + out.extend_from_slice(&(*len as u32).to_be_bytes()); + } + out.extend_from_slice(&data); + out +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index e77d422e..ca41a2dd 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -39,6 +39,14 @@ same MIT OR Apache-2.0 licence. Code comments keep the pull request's patch numb |---|---|---|---|---| | [#2770](https://github.com/storytold/pdfcraft/pull/2770) | Guillem Hermida (guillehr2) | [6fcf2a4](https://github.com/guillehr2/pdfcraft/commit/6fcf2a4aca29e943e28cb69017ff8f87d00656c6) | hayro-interpret `color.rs`, `interpret/mod.rs`, `interpret/path.rs`, `interpret/state.rs`, `pattern.rs`, `shading.rs`, `soft_mask.rs`, `x_object.rs` | Patches 35 and 36, plate mode: `InterpreterSettings::plate` draws one ink's separation as a gray film (`color::Plate`, `ColorSpace::ink_use`/`ink_tint`); the graphics state reads `OP`, `op` and `OPM` and an overprinting paint that doesn't specify the plate's ink leaves the plate as it is (ISO 32000-2 §8.6.7); `InterpreterSettings::ink_sink` reports spot inks; `color::CmykSimulation` converts process colour to an output intent's CMYK. Only the vendored interpreter part is ported; PdfCraft's `separations.rs` and Output Preview UI are not | +The output-intent conversion of patch 36 goes through a `color::CmykConversion` trait, so +that callers can convert with their own colour management. `CmykSimulation::new` keeps +#2770's moxcms conversion as the default, and `CmykSimulation::with_conversion` takes a +caller's. Lab and ICCBased colours reach the conversion as CIE XYZ relative to the D50 PCS +white (`color::PCS_WHITE`); the moxcms conversion takes them on through #2770's ProPhoto hub. +`crates/pdf/tests/plates.rs` adapts #2770's plate tests to the raster path, with its +synthetic ICC profiles in `crates/pdf/tests/support/synthetic_icc.rs`. + The shared snapshot adds immutable per-document ordinary decode limits, threaded through initial xrefs, object streams, fallback and repair. Existing constructors retain their previous default. Known-size image budgets and streaming content budgets remain distinct. diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 3733e299..9a1b69ee 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "784753b972d9a24167c8085f4e387ac7cc85007f4ab4ca83ab0c0aafe067c073" + "sha256": "ed70bdb9618cf4d65032e977efe60b4d36f868fa4040db1012bfaf9ab4f5fb6e" }, { "path": "src/context.rs", @@ -362,7 +362,7 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "2f50fcd65da262f4ef66c9611adcdb993015c57181bc2a51a551611152a59f5f" + "sha256": "6b0c0080f1301bcdc24c03777b056d0501815c82cf958923595b29266349f50c" }, { "path": "src/interpret/path.rs", diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 55904609..46dac18b 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -785,11 +785,11 @@ impl Lab { ] } - /// PdfCraft patch (36): the colour `input` in the separation hub (see [`Hub`]), relative + /// PdfCraft patch (36): the colour `input` as XYZ relative to [`PCS_WHITE`], relative /// colorimetric: the space's white point becomes the PCS white (Bradford-adapted). - fn to_hub(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { + fn to_pcs(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { let xyz = self.xyz(self.components(input, manual_scale)); - HUB.from_xyz(&Self::adapt(&self.usable_white_point(), &HUB.white, &xyz)) + Self::adapt(&self.usable_white_point(), &PCS_WHITE, &xyz) } } @@ -1091,10 +1091,11 @@ impl ICCProfile { }))) } - /// PdfCraft patch (36): `input` (whole colours of this profile's components) in the - /// separation hub, three values per colour in `rgb`. L*a*b* components are scaled to the - /// profile's encoding unless `manual_scale` says they already are (an Indexed palette's). - fn to_hub(&self, input: &[f32], manual_scale: bool, rgb: &mut [f32]) -> Option<()> { + /// PdfCraft patch (36): `input` (whole colours of this profile's components) as XYZ + /// relative to [`PCS_WHITE`], three values per colour in `xyz`, through the separation hub + /// (see [`Hub`]). L*a*b* components are scaled to the profile's encoding unless + /// `manual_scale` says they already are (an Indexed palette's). + fn to_pcs(&self, input: &[f32], manual_scale: bool, xyz: &mut [f32]) -> Option<()> { let transform = self .0 .to_hub @@ -1110,7 +1111,7 @@ impl ICCProfile { .as_ref()?; let n = self.0.number_components; let samples = input.len() / n.max(1); - if samples > SIMULATION_CHUNK || rgb.len() != samples.checked_mul(3)? { + if samples > SIMULATION_CHUNK || xyz.len() != samples.checked_mul(3)? { return None; } // Values outside the encoding (or NaN) never reach the tables. @@ -1123,7 +1124,12 @@ impl ICCProfile { (false, _) => unit(*v), }; } - transform.transform(encoded, rgb).ok() + transform.transform(encoded, xyz).ok()?; + for colour in xyz.chunks_exact_mut(3) { + let pcs = HUB.to_pcs(&[colour[0], colour[1], colour[2]]); + colour.copy_from_slice(&pcs); + } + Some(()) } fn is_srgb(&self) -> bool { @@ -1433,31 +1439,62 @@ const SIMULATION_CHUNK: usize = 256; /// /// - DeviceRGB as sRGB, as pages are drawn; CalRGB and CalGray through their sRGB colours; /// - Lab and ICCBased colours (RGB, gray, L\*a\*b\*, and CMYK in another profile) through -/// the separation hub, a D50 RGB wide enough for every surface colour (see [`Hub`]); +/// CIE XYZ relative to the PCS white ([`PCS_WHITE`]); /// - DeviceCMYK, ICCBased CMYK in this same profile, and DeviceGray stay as they are: gray is a /// device colour, which a CMYK device prints with black ink alone (§10.4.2.2). /// /// Conversions are relative colorimetric, the initial rendering intent (§8.6.5.8; the -/// graphics state's `ri` isn't read). A profile without colorimetric tables is used through -/// its perceptual ones (tag 0, which every LUT-based ICC.1 profile has). Black point -/// compensation isn't applied. +/// graphics state's `ri` isn't read). A [`CmykConversion`] converts into the profile's CMYK: +/// [`CmykSimulation::new`] makes one with moxcms, and [`CmykSimulation::with_conversion`] +/// takes the caller's. #[derive(Clone)] pub struct CmykSimulation(Arc); struct SimulationRepr { - description: Option, - perceptual: bool, digest: u128, - from_srgb: Arc, - from_hub: Arc, - to_srgb: Option>, + conversion: Box, } +/// The conversion into a [`CmykSimulation`]'s CMYK, relative colorimetric. Each call converts +/// whole colours, at most 256 of them; CMYK components are `0.0..=1.0`, four per colour. A +/// method returns `None` when it can't convert, and the colour then separates by the device +/// formulas. +pub trait CmykConversion: Send + Sync { + /// sRGB colours, gamma-encoded, three components in `0.0..=1.0` per colour, to CMYK. + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()>; + + /// CIE XYZ colours relative to [`PCS_WHITE`] (the Y of white is 1), three components per + /// colour, to CMYK. The source's white has already been adapted to the PCS white. + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()>; + + /// CMYK bytes, four per pixel, to sRGB bytes, three per pixel, to show what the printing + /// condition prints. + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + let _ = (cmyk, rgb); + None + } + + /// The profile's description, to name the printing condition. + fn description(&self) -> Option<&str> { + None + } + + /// Whether the conversions use the profile's perceptual tables, because it has no + /// colorimetric ones. + fn perceptual(&self) -> bool { + false + } +} + +/// The D50 white of the ICC profile connection space (ICC.1 §7.2.16), the white that +/// [`CmykConversion::from_pcs_xyz`] takes its colours relative to. +pub const PCS_WHITE: [f32; 3] = [0.9642, 1.0, 0.8249]; + impl Debug for CmykSimulation { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("CmykSimulation") - .field("description", &self.0.description) - .field("perceptual", &self.0.perceptual) + .field("description", &self.description()) + .field("perceptual", &self.perceptual()) .finish_non_exhaustive() } } @@ -1481,12 +1518,72 @@ pub enum NoSimulation { } impl CmykSimulation { + /// The printing condition of the ICC profile `profile`, if it is a CMYK profile whose + /// PCS-to-device tables convert, converted to with moxcms. A profile without colorimetric + /// tables is used through its perceptual ones (tag 0, which every LUT-based ICC.1 profile + /// has). Black point compensation isn't applied. + pub fn new(profile: &[u8]) -> Result { + Ok(Self::with_conversion( + profile, + MoxcmsConversion::new(profile)?, + )) + } + + /// The printing condition of the CMYK ICC profile `profile`, converted to by `conversion`, + /// which the caller made from that profile. ICCBased CMYK colours in `profile` itself stay + /// as they are. + pub fn with_conversion(profile: &[u8], conversion: impl CmykConversion + 'static) -> Self { + Self(Arc::new(SimulationRepr { + digest: hash128(profile), + conversion: Box::new(conversion), + })) + } + + /// The profile's description; `None` when it has none. + pub fn description(&self) -> Option<&str> { + self.0.conversion.description() + } + + /// Whether conversions use the profile's perceptual tables, because it has no + /// colorimetric ones. + pub fn perceptual(&self) -> bool { + self.0.conversion.perceptual() + } + + /// A digest of the profile's bytes, for cache keys. + pub(crate) fn digest(&self) -> u128 { + self.0.digest + } + + fn conversion(&self) -> &dyn CmykConversion { + self.0.conversion.as_ref() + } + + /// CMYK in this printing condition to sRGB, relative colorimetric, to show what it prints. + /// As [`cmyk_to_srgb8`]; also `None` when the conversion can't show CMYK. + pub fn cmyk_to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { + return None; + } + self.conversion().to_srgb8(cmyk, rgb) + } +} + +/// PdfCraft patch (36): the conversion [`CmykSimulation::new`] makes with moxcms. XYZ colours +/// pass through the separation hub (see [`Hub`]). +struct MoxcmsConversion { + description: Option, + perceptual: bool, + from_srgb: Arc, + from_hub: Arc, + to_srgb: Option>, +} + +impl MoxcmsConversion { /// The longest description kept, in characters. const MAX_DESCRIPTION: usize = 160; - /// The printing condition of the ICC profile `profile`, if it is a CMYK profile whose - /// PCS-to-device tables convert. - pub fn new(profile: &[u8]) -> Result { + fn new(profile: &[u8]) -> Result { let parsed = ColorProfile::new_from_slice(profile).map_err(|_| NoSimulation::Unreadable)?; if parsed.color_space != DataColorSpace::Cmyk { return Err(NoSimulation::NotCmyk); @@ -1500,40 +1597,40 @@ impl CmykSimulation { let (from_srgb, _) = transform_f32(&SRGB, Layout::Rgb, &parsed, Layout::Rgba) .ok_or(NoSimulation::NoPcsToDevice)?; let to_srgb = transform_u8(&parsed, Layout::Rgba, &SRGB, Layout::Rgb); - Ok(Self(Arc::new(SimulationRepr { + Ok(Self { description: profile_description(&parsed, Self::MAX_DESCRIPTION), perceptual, - digest: hash128(profile), from_srgb, from_hub, to_srgb, - }))) + }) + } +} + +impl CmykConversion for MoxcmsConversion { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + self.from_srgb.transform(rgb, cmyk).ok() } - /// The profile's description (its `desc` tag) without control characters; `None` when it - /// has none. - pub fn description(&self) -> Option<&str> { - self.0.description.as_deref() - } - - /// Whether conversions use the profile's perceptual tables, because it has no - /// colorimetric ones. - pub fn perceptual(&self) -> bool { - self.0.perceptual - } - - /// A digest of the profile's bytes, for cache keys. - pub(crate) fn digest(&self) -> u128 { - self.0.digest - } - - /// CMYK in this printing condition to sRGB, relative colorimetric, to show what it prints. - /// As [`cmyk_to_srgb8`]; also `None` when the profile has no device-to-PCS tables. - pub fn cmyk_to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { - if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { - return None; + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + let mut rgb = [0.0_f32; SIMULATION_CHUNK * 3]; + let rgb = rgb.get_mut(..xyz.len())?; + for (out, xyz) in rgb.chunks_exact_mut(3).zip(xyz.chunks_exact(3)) { + out.copy_from_slice(&HUB.from_pcs(&[xyz[0], xyz[1], xyz[2]])); } - self.0.to_srgb.as_ref()?.transform(cmyk, rgb).ok() + self.from_hub.transform(rgb, cmyk).ok() + } + + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + self.to_srgb.as_ref()?.transform(cmyk, rgb).ok() + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn perceptual(&self) -> bool { + self.perceptual } } @@ -1611,13 +1708,16 @@ fn transform_u8( static SRGB: LazyLock = LazyLock::new(ColorProfile::new_srgb); -/// PdfCraft patch (36): the separation hub, the RGB that Lab and ICCBased colours pass through -/// on their way to a simulated CMYK: ROMM RGB (ProPhoto) primaries, D50 white, gamma 1.8. Its +/// PdfCraft patch (36): the separation hub, the RGB that ICCBased colours leave their profile +/// in, and that [`MoxcmsConversion`] takes PCS XYZ to its CMYK through: ROMM RGB (ProPhoto) +/// primaries, D50 white, gamma 1.8. Its /// gamut holds practically every surface colour, so nothing printable clips on the way, and /// paper white and black land on exact grid points of the conversion's tables. (moxcms /// converts CMYK to CMYK and gray to CMYK only through an intermediate.) struct Hub { profile: ColorProfile, + /// Linear hub RGB to XYZ, row-major. + to_xyz: [f32; 9], /// XYZ to linear hub RGB, row-major. from_xyz: [f32; 9], /// The XYZ of hub white, `(1, 1, 1)`. @@ -1632,26 +1732,33 @@ impl Hub { let to_xyz = profile.rgb_to_xyz_matrix(); let from_xyz = to_xyz.inverse().v; let white = to_xyz.v.map(|row| (row[0] + row[1] + row[2]) as f32); + let flat = |m: [[f64; 3]; 3]| { + let mut out = [0.0_f32; 9]; + for (out, v) in out.iter_mut().zip(m.iter().flatten()) { + *out = *v as f32; + } + out + }; Self { profile, - from_xyz: [ - from_xyz[0][0] as f32, - from_xyz[0][1] as f32, - from_xyz[0][2] as f32, - from_xyz[1][0] as f32, - from_xyz[1][1] as f32, - from_xyz[1][2] as f32, - from_xyz[2][0] as f32, - from_xyz[2][1] as f32, - from_xyz[2][2] as f32, - ], + to_xyz: flat(to_xyz.v), + from_xyz: flat(from_xyz), white, } } - /// D50 XYZ (Y of white 1) as hub RGB, clipped to its gamut. - fn from_xyz(&self, xyz: &[f32; 3]) -> [f32; 3] { - CalRgb::matrix_product(&self.from_xyz, xyz).map(|v| unit(v).powf(1.0 / Self::GAMMA)) + /// XYZ relative to [`PCS_WHITE`] as hub RGB, clipped to its gamut. The PCS white is scaled + /// onto the hub's own D50 white, so that paper white lands on hub white exactly. + fn from_pcs(&self, xyz: &[f32; 3]) -> [f32; 3] { + let xyz = [0, 1, 2].map(|k| xyz[k] * self.white[k] / PCS_WHITE[k]); + CalRgb::matrix_product(&self.from_xyz, &xyz).map(|v| unit(v).powf(1.0 / Self::GAMMA)) + } + + /// Hub RGB as XYZ relative to [`PCS_WHITE`]; the inverse of [`Hub::from_pcs`] in gamut. + fn to_pcs(&self, rgb: &[f32; 3]) -> [f32; 3] { + let linear = rgb.map(|v| unit(v).powf(Self::GAMMA)); + let xyz = CalRgb::matrix_product(&self.to_xyz, &linear); + [0, 1, 2].map(|k| xyz[k] * PCS_WHITE[k] / self.white[k]) } } @@ -1700,6 +1807,8 @@ impl ColorSpace { if cmyk.len() != colours.checked_mul(4)? { return None; } + let conversion = simulation.conversion(); + // sRGB or PCS XYZ, by the space. let mut rgb = [0.0_f32; SIMULATION_CHUNK * 3]; let mut srgb = [0_u8; SIMULATION_CHUNK * 3]; for (input, cmyk) in input @@ -1709,12 +1818,12 @@ impl ColorSpace { let count = cmyk.len() / 4; let input = input.get(..count.checked_mul(n)?)?; let rgb = rgb.get_mut(..count * 3)?; - let to_cmyk = match self.0.as_ref() { + match self.0.as_ref() { ColorSpaceType::DeviceRgb => { for (out, v) in rgb.iter_mut().zip(input) { *out = unit(*v); } - &simulation.0.from_srgb + conversion.from_srgb(rgb, cmyk)?; } ColorSpaceType::CalRgb(_) | ColorSpaceType::CalGray(_) => { let srgb = srgb.get_mut(..count * 3)?; @@ -1722,21 +1831,20 @@ impl ColorSpace { for (out, v) in rgb.iter_mut().zip(srgb.iter()) { *out = u8_to_f32(*v); } - &simulation.0.from_srgb + conversion.from_srgb(rgb, cmyk)?; } ColorSpaceType::Lab(lab) => { for (out, colour) in rgb.chunks_exact_mut(3).zip(input.chunks_exact(3)) { - out.copy_from_slice(&lab.to_hub(colour, manual_scale)); + out.copy_from_slice(&lab.to_pcs(colour, manual_scale)); } - &simulation.0.from_hub + conversion.from_pcs_xyz(rgb, cmyk)?; } ColorSpaceType::ICCBased(icc) if self.simulated_by(simulation) => { - icc.to_hub(input, manual_scale, rgb)?; - &simulation.0.from_hub + icc.to_pcs(input, manual_scale, rgb)?; + conversion.from_pcs_xyz(rgb, cmyk)?; } _ => return None, - }; - to_cmyk.transform(rgb, cmyk).ok()?; + } } Some(()) } @@ -1848,7 +1956,7 @@ impl ColorSpace { /// PdfCraft patch (35): a process colour as DeviceCMYK: CMYK spaces (DeviceCMYK, ICC with /// four components) as they are, gray as black only (`k = 1 − gray`, §10.4.2.2), and RGB, - /// CIE-based and three-component ICC spaces through sRGB by [`rgb_to_cmyk`]. Patch 32: + /// CIE-based and three-component ICC spaces through sRGB by [`rgb_to_cmyk`]. Patch 36: /// the spaces `simulation` converts go through it instead, and only fall back to those /// formulas if the conversion fails. fn process_cmyk( @@ -1933,7 +2041,7 @@ impl ColorSpace { pub(crate) struct PlateSpace { inner: ColorSpace, ink: Ink, - /// Patch 32: the printing condition process colour converts to, if any. + /// Patch 36: the printing condition process colour converts to, if any. simulation: Option, } diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 99e21f7d..70bb05c7 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -122,7 +122,7 @@ pub struct InterpreterSettings { pub preview: bool, /// PdfCraft patch (35): draw one ink's separation instead of the composite page (see /// [`Plate`](crate::color::Plate)). Soft masks still draw in colour: a mask's luminosity - /// comes from its group's colours, not from any plate. Patch 32: the plate's `simulation` + /// comes from its group's colours, not from any plate. Patch 36: the plate's `simulation` /// converts process colour to an output intent's CMYK. pub plate: Option, /// PdfCraft patch (35): told about each spot ink a paint uses (colours, images, shadings