vendor: convert plate-mode CMYK through a CmykConversion trait

CmykSimulation converts process colour to an output intent's CMYK
through a color::CmykConversion: sRGB colours, and CIE XYZ relative to
the D50 PCS white (color::PCS_WHITE) for Lab and ICCBased colours.
CmykSimulation::new keeps the moxcms conversion from pdfcraft #2770,
which takes XYZ on through its ProPhoto hub; with_conversion takes a
caller's, so a crate can separate with its own colour management.

crates/pdf/tests/plates.rs adapts #2770's plate tests to the raster
path: process, spot, DeviceN, All/None, ICC CMYK, gray and RGB colours,
images, shadings, uncoloured patterns, soft masks, OP/op/OPM, the ink
sink, output-intent simulation with #2770's synthetic ICC profiles, and
a caller's conversion. The ledger and NOTICE name the pull request.

Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com>
This commit is contained in:
aionggandGuillem Hermida committed 2026-10-20 02:53:37 +00:00
1 parent 2c8c3ad261
commit 7764afb2a7
7 files changed
+874 -79

No files matched your search

+4
View File
@@ -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,
+448
View File
@@ -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<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> {
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<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 {
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> {
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<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 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<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_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<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_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:?}");
}
}
+227
View File
@@ -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<u8> {
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<u8>)> = 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<u8> {
// 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<u8>)> =
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<f64> {
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<f64>) -> Vec<u8> {
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<u8> {
let ascii: Vec<u8> = 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<u8> {
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<u8> {
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<u8>)]) -> Vec<u8> {
let table_end = 128 + 4 + 12 * tags.len();
let mut data: Vec<u8> = 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
}
+8
View File
@@ -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.
+2 -2
View File
@@ -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",
+184 -76
View File
@@ -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<SimulationRepr>);
struct SimulationRepr {
description: Option<String>,
perceptual: bool,
digest: u128,
from_srgb: Arc<TransformF32Executor>,
from_hub: Arc<TransformF32Executor>,
to_srgb: Option<Arc<Transform8BitExecutor>>,
conversion: Box<dyn CmykConversion>,
}
/// 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<Self, NoSimulation> {
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<String>,
perceptual: bool,
from_srgb: Arc<TransformF32Executor>,
from_hub: Arc<TransformF32Executor>,
to_srgb: Option<Arc<Transform8BitExecutor>>,
}
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<Self, NoSimulation> {
fn new(profile: &[u8]) -> Result<Self, NoSimulation> {
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<ColorProfile> = 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<CmykSimulation>,
}
+1 -1
View File
@@ -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<crate::color::Plate>,
/// PdfCraft patch (35): told about each spot ink a paint uses (colours, images, shadings