diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 5a6c0e70..3400a007 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -3,9 +3,9 @@ name: Shared PDF on: push: branches: [main] - paths: ["crates/pdf/**", "crates/plate-test/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] + paths: ["crates/pdf/**", "crates/plate-test/**", "crates/prepress/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] pull_request: - paths: ["crates/pdf/**", "crates/plate-test/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] + paths: ["crates/pdf/**", "crates/plate-test/**", "crates/prepress/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] workflow_dispatch: permissions: @@ -87,12 +87,15 @@ jobs: - run: cargo test -p craft-pdf --all-features --locked --target wasm32-unknown-unknown - run: cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked --target wasm32-unknown-unknown plate-test: - name: Plate test scenes + name: Plate tests and craft-prepress runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: clippy - uses: Swatinem/rust-cache@v2 with: key: craft-plate-test - - run: cargo test -p craft-plate-test --locked + - run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --locked -- -D warnings + - run: cargo test -p craft-plate-test -p craft-prepress --locked diff --git a/Cargo.lock b/Cargo.lock index a618b628..837c5cbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,6 +646,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-prepress" +version = "0.1.0" +dependencies = [ + "craft-cms", + "craft-pdf", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 67efc912..ec3c1358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } craft-i18n = { path = "crates/i18n" } +craft-prepress = { path = "crates/prepress" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } diff --git a/NOTICE b/NOTICE index 858038c4..fc0b0f61 100644 --- a/NOTICE +++ b/NOTICE @@ -99,8 +99,16 @@ Optional PDF reader Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 (https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their upstream licence files. These patched sources are transferred from PdfCraft revision -7437bde378f43856eada40af25b983812e615e8a (https://github.com/storytold/pdfcraft). +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. +Knockout transparency groups in hayro-interpret and hayro are ported from PdfCraft pull request +#2974 by mtlcl410, commit bae69819594a6385a027fec1f67e6b3c36c259b4 +(https://github.com/storytold/pdfcraft/pull/2974), MIT OR Apache-2.0; +crates/pdf/tests/knockout.rs adapts its test. 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/README.md b/crates/pdf/README.md index 691b35b0..c8efee17 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -56,7 +56,7 @@ Cancellation is polled between operators, so a decoder may finish before noticin ## Backend provenance and consumer pins The shared reader vendors the current patched Hayro trees from PdfCraft revision -`7437bde378f43856eada40af25b983812e615e8a`. See +`ec0a965074f376a5bf610435948e4e5184bf3299`. See [`vendor/README.craft-reader.md`](../../vendor/README.craft-reader.md) for the transfer and local patches. Lopdf, COS editing, inspection fallback and application caches remain app-owned. diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index bf1fc931..9b170d1f 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -26,6 +26,8 @@ pub struct Options { /// Straight-alpha sRGB bytes; output pixels are always premultiplied. pub background: [u8; 4], /// Generic solid-colour mapping; reading presets and print bypass policy belong to callers. + /// It also maps a plate's gray film (`InterpreterSettings::plate`), so a plate render that + /// must keep its tints leaves it `None`. Knockout clears are never mapped. pub color_replacement: Option, } diff --git a/crates/pdf/tests/knockout.rs b/crates/pdf/tests/knockout.rs new file mode 100644 index 00000000..039674b9 --- /dev/null +++ b/crates/pdf/tests/knockout.rs @@ -0,0 +1,162 @@ +//! Knockout transparency groups through the raster path. Adapted from PdfCraft #2974 by mtlcl410 +//! (`raster::tests::knockout_groups_replace_what_their_elements_cover` in pdfcraft-render at +//! bae69819594a6385a027fec1f67e6b3c36c259b4, MIT OR Apache-2.0). +#![cfg(feature = "raster")] + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::{ + open_slice, + raster::{self, Options, RenderCache}, +}; + +/// A 300 × 200 pt page that draws form `Fm1` (a transparency group with `group` entries and +/// content `form`), then a green square at 50 % opacity. +fn knockout_pdf(group: &str, form: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length 39 >> stream +/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream +{form} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream +/A gs 0 0 1 rg 100 50 150 120 re f +endstream endobj +7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream +0000> +endstream endobj +8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream +/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f +endstream endobj +9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream +0000FF0000FF> +endstream endobj +11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream +00FF> +endstream endobj +12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream +/H gs q 160 0 0 100 60 50 cm /Img Do Q +endstream endobj +13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream +/H gs /In3 Do +endstream endobj +14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream +0 0 1 rg 100 50 150 120 re f +endstream endobj +15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj +16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream +/Pattern cs /P scn 100 50 150 120 re f +endstream endobj +17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + form.len() + ) + .into_bytes() +} + +/// The page rendered over white, as RGBA8, 300 × 200 pixels. +fn render(group: &str, form: &str) -> Vec { + let pdf = knockout_pdf(group, form); + let doc = open_slice(&pdf, None).unwrap(); + let options = Options { background: [255; 4], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &InterpreterSettings::default(), &options).unwrap(); + assert_eq!((raster.width, raster.height), (300, 200)); + raster.bytes().to_vec() +} + +/// The RGB at PDF point (x, y); pixel rows run top down. +fn px(page: &[u8], x: usize, y: usize) -> [u8; 3] { + let p = &page[((200 - y) * 300 + x) * 4..][..3]; + [p[0], p[1], p[2]] +} + +fn check(page: &[u8], x: usize, y: usize, want: [u8; 3], what: &str) { + let got = px(page, x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}"); +} + +/// In a knockout transparency group (`/K true`) each element composites with the group's initial +/// backdrop, not with the elements drawn before it in the group, over the element's shape. A blue +/// square at 50 % opacity over a red one at 50 % shows only the blue where they overlap; without +/// `/K` the two blend. The same holds for an image (its shape is its rectangle; a soft-masked +/// image's shape is its mask, so its transparent part leaves what is under it) and for a nested +/// group (its shape is the union of its elements' shapes, while its own elements still blend with +/// each other). +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn knockout_groups_replace_what_their_elements_cover() { + let red = "/A gs 1 0 0 rg 20 20 150 150 re f "; + let ko = "/I true /K true"; + + let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&squares, 130, 100, [128, 128, 255], "knocked out"); + check(&squares, 50, 40, [255, 128, 128], "red alone"); + check(&squares, 210, 100, [128, 128, 255], "blue alone"); + check(&squares, 280, 190, [255, 255, 255], "paper"); + // Page content after the group composites normally again: green at 50 % over the red. + check(&squares, 30, 30, [128, 191, 64], "after the group"); + let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&plain, 130, 100, [128, 64, 191], "blended without /K"); + + // An image at 50 % replaces the red under its rectangle. + let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q")); + check(&image, 100, 100, [128, 128, 255], "image knocks out"); + check(&image, 40, 40, [255, 128, 128], "red beside the image"); + // A soft-masked image: its transparent left half leaves the red, its opaque half replaces it. + let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q"); + check(&masked, 100, 100, [255, 0, 0], "under the transparent half"); + check(&masked, 155, 100, [0, 0, 255], "under the opaque half"); + + // A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape. + let nested = render(ko, &format!("{red}/In Do")); + check(&nested, 130, 100, [191, 191, 255], "nested group knocks out"); + check(&nested, 50, 40, [255, 128, 128], "red beside the nested group"); + // A nested group at full opacity: it knocks out the red as a whole, and its own two squares + // at 50 % still blend with each other where they overlap. + let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do"); + check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red"); + check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend"); + check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group"); + + // An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red. + let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do"); + check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group"); + // A group inside a nested group knocks out the red as well. + let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do"); + check(&deep, 130, 100, [128, 128, 255], "group in a nested group"); + check(&deep, 40, 40, [255, 0, 0], "red beside it"); + + // A stencil mask must not erase the red beside it. Where the stencil paints (every sample 0) + // its blue is drawn; a stencil composites rather than knocking the red out, and its exact + // color also depends on its fill opacity (pdfcraft #2975), so only "blue was drawn here" is + // pinned. + let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q")); + check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil"); + let under = px(&stencil, 130, 100); + assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}"); + + // A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path + // inside the box: the erase pass clears only that, and the red outside the box but inside + // the path stays. + let shaded = render(ko, &format!("{red}/Shg Do")); + check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box"); + check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box"); + + // An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it + // doesn't clear what is under its rectangle either. + let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q")); + check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn"); + // A tiny image still knocks out in a final render. + let tiny = render(ko, &format!("{red}q 3 0 0 3 100 100 cm /Img Do Q")); + check(&tiny, 101, 101, [128, 128, 255], "a tiny image"); +} diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs new file mode 100644 index 00000000..632018c7 --- /dev/null +++ b/crates/pdf/tests/plates.rs @@ -0,0 +1,477 @@ +//! 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 honored. +fn separate(pdf: &[u8]) -> Plates { + plates(pdf, &inks(&["Gold"]), true, None) +} + +/// A spot color whose alternate is a yellowish CMYK. +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]"; + +fn gold_resource() -> String { + format!("/ColorSpace << /CS0 {GOLD} >>") +} + +const CGATS: &[u8] = include_bytes!("../../../vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc"); + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn device_cmyk_puts_each_component_on_its_own_plate() { + let s = plates(&page_pdf("0.2 0.4 0.6 0.8 k 0 0 40 40 re f", "", &[]), &inks(&[]), true, None); + assert_eq!(s.at(20, 20), [20, 40, 60, 80]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_spot_color_paints_its_own_plate_and_knocks_out_the_process_plates() { + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /CS0 cs 0.5 scn 10 10 20 20 re f", &gold_resource(), &[])); + assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "knocked out under the spot color"); + assert_eq!(s.at(2, 2), [0, 0, 0, 100, 0]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn overprinting_keeps_the_plates_a_color_does_not_specify() { + let resources = format!("{} /ExtGState << /GS1 << /op true >> >>", gold_resource()); + let pdf = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS1 gs /CS0 cs 0.5 scn 10 10 20 20 re f", &resources, &[]); + assert_eq!(separate(&pdf).at(20, 20), [0, 0, 0, 100, 50], "overprinted"); + assert_eq!(plates(&pdf, &inks(&["Gold"]), false, None).at(20, 20), [0, 0, 0, 0, 50], "overprint simulation off: knocked out"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn op_sets_stroke_overprint_and_fill_overprint_too_without_op() { + let resources = format!("{} /ExtGState << /GS1 << /OP true >> /GS2 << /OP true /op false >> >>", gold_resource()); + let fill = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f /{gs} gs /CS0 cs 1 scn 10 10 20 20 re f"), &resources, &[]); + assert_eq!(separate(&fill("GS1")).at(20, 20)[3], 100, "OP alone also sets op"); + assert_eq!(separate(&fill("GS2")).at(20, 20)[3], 0, "op false keeps fills knocking out"); + let stroke = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS2 gs /CS0 CS 1 SCN 8 w 0 20 m 40 20 l S", &resources, &[]); + assert_eq!(separate(&stroke).at(20, 20), [0, 0, 0, 100, 100], "strokes overprint with OP"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn nonzero_overprint_mode_leaves_zero_device_cmyk_components() { + let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> /M0 << /OP true /OPM 0 >> >>"; + let pdf = |gs: &str| page_pdf(&format!("1 0 0 0 k 0 0 40 40 re f /{gs} gs 0 1 0 0 k 10 10 20 20 re f"), resources, &[]); + let process = inks(&[]); + assert_eq!(plates(&pdf("M1"), &process, true, None).at(20, 20), [100, 100, 0, 0], "OPM 1: the zero cyan leaves the plate"); + assert_eq!(plates(&pdf("M0"), &process, true, None).at(20, 20), [0, 100, 0, 0], "OPM 0: zero components paint"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn gray_and_rgb_are_process_color_and_never_take_the_nonzero_overprint_mode() { + let process = inks(&[]); + let gray = page_pdf("1 0 0 0 k 0 0 40 40 re f /M1 gs 0.25 g 10 10 20 20 re f", "/ExtGState << /M1 << /OP true /OPM 1 >> >>", &[]); + assert_eq!(plates(&gray, &process, true, None).at(20, 20), [0, 0, 0, 75], "gray is black only, and knocks out cyan"); + let s = plates(&page_pdf("1 0 0 rg 0 0 20 40 re f 0.2 0.2 0.2 rg 20 0 20 40 re f", "", &[]), &process, true, None); + assert_eq!(s.at(10, 20), [0, 100, 100, 0], "red"); + assert_eq!(s.at(30, 20), [0, 0, 0, 80], "a neutral gray goes to black (10.4.2.4)"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn devicen_components_go_to_the_plates_they_name() { + let resources = "/ColorSpace << /CS0 [/DeviceN [/Cyan /Silver /None] /DeviceCMYK 5 0 R] >>"; + let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ pop 0.5 mul 0 0 }"); + let s = plates(&page_pdf("/CS0 cs 0.3 0.7 1 scn 0 0 40 40 re f", resources, &[function]), &inks(&["Silver"]), true, None); + assert_eq!(s.at(20, 20), [30, 0, 0, 0, 70]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_all_colorant_paints_every_plate_and_none_paints_nothing() { + let gray = "/DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let resources = format!("/ColorSpace << /CS0 {GOLD} /Reg [/Separation /All {gray}] /No [/Separation /None {gray}] >>"); + let content = "/CS0 cs 0.2 scn 0 0 40 40 re f /Reg cs 1 scn 0 0 20 40 re f /No cs 1 scn 20 0 20 40 re f"; + let s = separate(&page_pdf(content, &resources, &[])); + assert_eq!(s.at(10, 20), [100, 100, 100, 100, 100], "registration color"); + assert_eq!(s.at(30, 20), [0, 0, 0, 0, 20], "None leaves every plate as it is"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn icc_based_cmyk_is_separated_as_its_components() { + let pdf = page_pdf("/CS0 cs 0.1 0.3 0.5 0.7 scn 0 0 40 40 re f", "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", &[stream("/N 4", CGATS)]); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [10, 30, 50, 70]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn images_are_separated_sample_by_sample() { + let cmyk = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[255, 0, 0, 0, 0, 0, 0, 128]); + let s = plates(&page_pdf("q 40 0 0 40 0 0 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &[cmyk]), &inks(&[]), true, None); + assert_eq!(s.at(10, 20), [100, 0, 0, 0]); + assert_eq!(s.at(30, 20), [0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_spot_color_image_overprints_like_any_paint() { + let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[128]); + let resources = "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true >> >>"; + let pdf = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} q 20 0 0 20 10 10 cm /Im1 Do Q"), resources, std::slice::from_ref(&image)); + assert_eq!(separate(&pdf("/GS1 gs")).at(20, 20), [0, 0, 0, 100, 50]); + assert_eq!(separate(&pdf("")).at(20, 20), [0, 0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shadings_are_separated_through_their_color_space() { + let shading = format!( + "<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 40 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>" + ); + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /Sh1 sh", "/Shading << /Sh1 5 0 R >>", &[shading.into_bytes()])); + let (left, right) = (s.at(4, 20), s.at(36, 20)); + assert!(left[4] < 20 && right[4] > 80, "a gradient on the spot plate: {left:?} {right:?}"); + assert_eq!((left[3], right[3]), (0, 0), "the shading knocks out the black under it"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn an_uncolored_pattern_paints_the_film_of_the_color_it_is_used_with() { + let cell = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", b"0 0 10 10 re f"); + let resources = format!("/ColorSpace << /PCS [/Pattern {GOLD}] >> /Pattern << /P1 5 0 R >>"); + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /PCS cs 0.5 /P1 scn 0 0 40 40 re f", &resources, &[cell])); + assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "the pattern's spot color, knocking out black"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn soft_masks_keep_the_luminosity_of_their_own_colors() { + // A mask drawn in blue lets about a tenth of the black through. Drawn as the black + // plate's film, blue (no black ink) would read as white and let all of it through. + let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceRGB >>", b"0 0 1 rg 0 0 40 40 re f"); + let resources = "/ExtGState << /SM << /SMask << /S /Luminosity /G 5 0 R >> >> >>"; + let s = plates(&page_pdf("/SM gs 0 0 0 1 k 0 0 40 40 re f", resources, &[group]), &[Ink::Black], true, None); + let black = s.at(20, 20)[0]; + assert!((1..30).contains(&black), "black through a blue luminosity mask: {black}%"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn 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 color"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn malformed_color_spaces_and_overprint_parameters_draw_what_they_can() { + let resources = "/ColorSpace << /A [/DeviceN [] /DeviceGray 0] /B [/Separation] /C [/Indexed /DeviceCMYK 300 <00>] >> /ExtGState << /G << /OP 7 /OPM /X /op (y) >> >>"; + let pdf = page_pdf("/G gs /A cs 1 scn 0 0 9 9 re f /B cs 1 scn /C cs 255 scn 0 0 40 40 re f", resources, &[]); + plates(&pdf, &inks(&["Gold"]), true, None); +} + +// Separating through an output intent's CMYK profile (vendored patch 36). + +const PRESS: &str = "PdfCraft synthetic press"; + +fn press_simulation() -> CmykSimulation { + CmykSimulation::new(&synthetic_icc::cmyk_profile(PRESS, true)).unwrap() +} + +/// What the synthetic press prints for CIE L*a*b*, in whole percent. +fn press(l: f64, a: f64, b: f64) -> Vec { + synthetic_icc::btoa(l, a, b).iter().map(|v| (v * 100.0).round() as u32).collect() +} + +/// Whether every ink is within `tolerance` percent of `expected`. +fn near(got: &[u32], expected: &[u32], tolerance: u32) -> bool { + got.len() == expected.len() && got.iter().zip(expected).all(|(g, e)| g.abs_diff(*e) <= tolerance) +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn rgb_separates_through_the_simulated_cmyk_profile() { + let simulation = press_simulation(); + assert_eq!(simulation.description(), Some(PRESS)); + assert!(!simulation.perceptual()); + let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]); + // The press prints a neutral with equal inks, where the device formulas put it on black + // alone. + let gray = press(synthetic_icc::srgb_gray_lightness(0.5), 0.0, 0.0); + let got = plates(&pdf, &inks(&[]), true, Some(&simulation)).at(20, 20); + assert!(near(&got, &gray, 2), "{got:?}, the press prints {gray:?}"); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn only_cmyk_profiles_with_pcs_to_device_tables_simulate() { + let whole = synthetic_icc::cmyk_profile(PRESS, true); + assert_eq!(CmykSimulation::new(&synthetic_icc::gray_profile("Gray")).err(), Some(NoSimulation::NotCmyk)); + assert_eq!(CmykSimulation::new(&synthetic_icc::cmyk_profile("Proofing only", false)).err(), Some(NoSimulation::NoPcsToDevice)); + assert_eq!(CmykSimulation::new(b"not a profile").err(), Some(NoSimulation::Unreadable)); + assert_eq!(CmykSimulation::new(&whole[..300]).err(), Some(NoSimulation::Unreadable)); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn cmyk_in_the_simulated_profile_passes_through_and_other_cmyk_converts() { + let profile = synthetic_icc::cmyk_profile(PRESS, true); + let simulation = CmykSimulation::new(&profile).unwrap(); + // Object 5 is the press profile, 6 another CMYK profile. + let resources = "/ColorSpace << /Same [/ICCBased 5 0 R] /Other [/ICCBased 6 0 R] >>"; + let content = "0.2 0.4 0.6 0.8 k 0 0 10 40 re f /Same cs 0.1 0.3 0.5 0.7 scn 10 0 10 40 re f /Other cs 0 0 0 1 scn 30 0 10 40 re f"; + let pdf = page_pdf(content, resources, &[stream("/N 4", &profile), stream("/N 4", CGATS)]); + let s = plates(&pdf, &inks(&[]), true, Some(&simulation)); + assert_eq!(s.at(5, 20), [20, 40, 60, 80], "DeviceCMYK is the press's own CMYK"); + assert_eq!(s.at(15, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile"); + let other = s.at(35, 20); + assert!(other.iter().all(|v| *v > 70), "black alone in another profile is a four-ink black on this press: {other:?}"); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(35, 20), [0, 0, 0, 100], "without a simulation ICC CMYK is taken as it is"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn device_gray_stays_on_the_black_plate_under_a_simulation() { + let s = plates(&page_pdf("0.25 g 0 0 40 40 re f", "", &[]), &inks(&[]), true, Some(&press_simulation())); + assert_eq!(s.at(20, 20), [0, 0, 0, 75]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn cie_and_icc_colors_convert_through_the_profile_relative_to_their_white() { + let resources = + "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Gray [/ICCBased 5 0 R] >>"; + let content = "/D50 cs 50 20 -10 scn 0 0 10 40 re f /D65 cs 100 0 0 scn 10 0 10 40 re f /Gray cs 0.5 scn 20 0 10 40 re f"; + let pdf = page_pdf(content, resources, &[stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2"))]); + let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation())); + assert!(near(&s.at(5, 20), &press(50.0, 20.0, -10.0), 2), "Lab: {:?}", s.at(5, 20)); + assert_eq!(s.at(15, 20), [0, 0, 0, 0], "a Lab space's own white prints as paper, whatever its white point"); + let lightness = 116.0 * 0.5_f64.powf(2.2).cbrt() - 16.0; + assert!(near(&s.at(25, 20), &press(lightness, 0.0, 0.0), 2), "ICC gray: {:?}", s.at(25, 20)); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn images_convert_through_the_simulation_sample_by_sample() { + let rgb = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB", &[128, 128, 128, 255, 255, 255]); + // More samples than palette entries: the palette is separated once. + let indexed = stream( + "/Type /XObject /Subtype /Image /Width 4 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 <808080FFFFFF>]", + &[0, 0, 1, 1], + ); + let content = "q 40 0 0 20 0 20 cm /Im1 Do Q q 40 0 0 20 0 0 cm /Im2 Do Q"; + let pdf = page_pdf(content, "/XObject << /Im1 5 0 R /Im2 6 0 R >>", &[rgb, indexed]); + let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation())); + let gray = press(synthetic_icc::srgb_gray_lightness(128.0 / 255.0), 0.0, 0.0); + for y in [10, 30] { + assert!(near(&s.at(10, y), &gray, 2), "y = {y}: {:?}, the press prints {gray:?}", s.at(10, y)); + assert_eq!(s.at(30, y), [0, 0, 0, 0], "y = {y}: white prints no ink"); + } +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_simulation_shows_its_cmyk_through_its_own_profile() { + // The inks the press prints sRGB gray 128 with show as that gray: its two tables invert + // each other. + let ink = (255.0 * (1.0 - synthetic_icc::srgb_gray_lightness(128.0 / 255.0) / 100.0)).round() as u8; + let mut shown = [0; 3]; + press_simulation().cmyk_to_srgb8(&[ink; 4], &mut shown).unwrap(); + assert!(shown.iter().all(|c| c.abs_diff(128) <= 4), "{shown:?}"); + assert!(press_simulation().cmyk_to_srgb8(&[0; 4], &mut [0; 4]).is_none(), "lengths disagree"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn mutated_profiles_simulate_or_are_refused_without_panicking() { + // 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_color() { + let profile = synthetic_icc::cmyk_profile(PRESS, true); + let xyz = Arc::default(); + let simulation = CmykSimulation::with_conversion(&profile, Fixed(Arc::clone(&xyz))); + assert_eq!(simulation.description(), Some("fixed")); + assert!(simulation.cmyk_to_srgb8(&[0; 4], &mut [0; 3]).is_none(), "no way to show CMYK"); + let resources = "/ColorSpace << /Lab [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Same [/ICCBased 5 0 R] >>"; + let content = "1 0 0 rg 0 0 10 40 re f /Lab cs 100 0 0 scn 10 0 10 40 re f 0.2 0.4 0.6 0.8 k 20 0 10 40 re f \ + /Same cs 0.1 0.3 0.5 0.7 scn 30 0 10 40 re f"; + let s = plates(&page_pdf(content, resources, &[stream("/N 4", &profile)]), &inks(&[]), true, Some(&simulation)); + assert_eq!(s.at(5, 20), [10, 20, 30, 40], "DeviceRGB through from_srgb"); + assert_eq!(s.at(15, 20), [50, 60, 70, 80], "Lab through from_pcs_xyz"); + assert_eq!(s.at(25, 20), [20, 40, 60, 80], "DeviceCMYK as it is"); + assert_eq!(s.at(35, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile as it is"); + let xyz = xyz.lock().unwrap(); + assert!(!xyz.is_empty()); + for c in xyz.iter() { + assert!(c.iter().zip(PCS_WHITE).all(|(v, w)| (v - w).abs() < 1e-3), "a Lab white arrives as the PCS white: {c:?}"); + } +} + +/// A knockout group (`/K true`, pdfcraft #2974) of two process squares at 50 % opacity separates +/// with only the top square where they overlap: its plates replace the bottom square's there. +/// Without `/K` the two blend on every plate. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_knockout_group_separates_with_only_the_top_object_in_the_overlap() { + let group = |entries: &str| { + let form = "/A gs 1 0 0 0 k 0 0 25 25 re f 0 1 0 0 k 15 15 25 25 re f"; + page_pdf( + "/Fm Do", + "/XObject << /Fm 5 0 R >>", + &[stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency {entries} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >>" + ), + form.as_bytes(), + )], + ) + }; + // Device rows run top down: the overlap is at (20, 20), cyan alone at (5, 35), magenta alone + // at (35, 5). + let knockout = plates(&group("/K true"), &inks(&[]), true, None); + assert_eq!(knockout.at(20, 20), [0, 50, 0, 0], "only the magenta square in the overlap"); + assert_eq!(knockout.at(5, 35), [50, 0, 0, 0]); + assert_eq!(knockout.at(35, 5), [0, 50, 0, 0]); + let blended = plates(&group(""), &inks(&[]), true, None); + assert_eq!(blended.at(20, 20), [25, 50, 0, 0], "both squares without /K"); +} diff --git a/crates/pdf/tests/support/synthetic_icc.rs b/crates/pdf/tests/support/synthetic_icc.rs new file mode 100644 index 00000000..97aeb30a --- /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 colors 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/crates/prepress/Cargo.toml b/crates/prepress/Cargo.toml new file mode 100644 index 00000000..3e65e8b9 --- /dev/null +++ b/crates/prepress/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-prepress" +description = "Prepress for the Craft apps: ink model, per-ink plates rendered from a PDF, coverage, ink limits and composite preview" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-pdf = { workspace = true, features = ["raster"] } +craft-cms = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/prepress/README.md b/crates/prepress/README.md new file mode 100644 index 00000000..3c0dcd6b --- /dev/null +++ b/crates/prepress/README.md @@ -0,0 +1,169 @@ +# craft-prepress + +Prepress for the Craft apps: separations, ink limits and the ink manager. + +- `ink`: the ink model the writers and previews share. `InkSet` holds the four process inks + (always first, in plate order) and the spot inks of a job, and is the job's ink manager + (aliases, spots to process); `InkColor` is a color as ink tints (`Separation`, `DeviceN`, + registration or none); `Overprint` holds `op`, `OP` and `OPM`. Spot names are compared as + PDF names: `/Gold#20Foil` and `Gold Foil` are one ink. +- `plates`: one 8-bit plane per ink (255 = 100% ink) rendered from a PDF page. +- `coverage`: ink coverage at a pixel and over a page, total area coverage (TAC) per pixel, + and the pixels and regions over a TAC limit. +- `composite`: an sRGB preview of chosen plates. +- `cms`: process color conversion into a CMYK printing condition through craft-cms. + +A transparency flattener is planned as a module behind a `flatten` feature. + +## Plates from an exported PDF + +An app exports its PDF as usual, then separates a page of it: + +```rust +use craft_prepress::{plates, InkId, Options, Process}; + +let p = plates(&pdf, 0, 150.0, &Options::default())?; +for (id, ink) in p.inks.iter() { + let plane = p.plane(id); // p.width × p.height tints, row-major + println!("{}: {} pixels inked", ink.name(), plane.map_or(0, |t| t.iter().filter(|v| **v > 0).count())); +} +let black = p.tint(InkId::process(Process::Black), 10, 10); +let gold = p.inks.lookup("Gold").and_then(|id| p.tint(id, 10, 10)); +``` + +The plates are the PDF as printed, so a preview built on them shows what the writer produced, +not what the document model intended. + +How a plate is made: the page renders once per ink through craft-pdf's `raster` with +hayro-interpret's plate mode (`InterpreterSettings::plate`). Every paint draws its tint of +that ink as gray film. A paint that overprints (`op`/`OP`, and `OPM 1` for zero DeviceCMYK +components) without naming the ink leaves the film as it is; with `Options::overprint` off, +every paint knocks out. Clipping, anti-aliasing, soft masks, transparency and knockout groups +stay the renderer's own. `Separation` and `DeviceN` colorants paint their own plates (`All` +every plate, `None` none), DeviceCMYK its components, DeviceGray black only. + +Inks are found by a low-resolution pass that records each spot ink a paint uses, in the order +first painted. Spot inks used only inside soft masks are left out. Each spot gets a Lab +alternate measured from its sRGB appearance at 100%, so out-of-sRGB spots are clipped. + +### Process color + +RGB, CIE-based and ICCBased colors become CMYK through, in order: + +1. the CMYK profile of the page's or the document's output intent (a `GTS_PDFX` intent first), + when craft-cms can convert into it; +2. `Options::fallback`: craft-cms's Coated FOGRA39 profile (the default), a profile the caller + gives, or the ISO 32000-2 §10.4.2.4 device formulas. + +`Plates::conversion` says which was used. Conversions are relative colorimetric without black +point compensation. DeviceCMYK, and ICCBased CMYK in the printing condition's own profile, +separate as they are. + +### Caps + +| Constant | Value | Why | +|---|---|---| +| `MAX_SPOT_INKS` | 32 spot inks per page | A `DeviceN` space has at most 32 colorants, and each plate costs a render and a byte per pixel. Further spots are listed in `Plates::unseparated`. | +| `MAX_PLATE_PIXELS` | 4 MP per plate (2000 × 2000) | A page with many inks stays within a few hundred megabytes. The resolution is lowered to fit; `Plates::dpi` reports the one used. | + +The renderer's side limit (`craft_pdf::raster::MAX_SIDE`) also lowers the resolution. + +### Approximations + +- Non-separable blend modes and groups that blend in RGB are approximated per ink. +- The graphics state's rendering intent is not read; transfer functions and halftones are not + applied. + +## Coverage and TAC + +Every reading takes `enabled`, indexed by `InkId::index` like a Separations Preview's +toggles: inks past its end count, so `&[]` counts every ink. Values are percent of one ink at +100%, so TAC runs to 100 × the number of inks. + +| Call | Gives | +|---|---| +| `coverage_at(x, y, enabled)` | `Coverage`: each ink's tint and the TAC of the enabled inks at a pixel | +| `pixel_at(u, v)` | The pixel under a point given as fractions of the page, for a pointer readout | +| `tac(enabled)` | TAC per pixel, row-major | +| `stats(enabled)` | `Stats`: per ink `InkStats { max, mean, area }`; the page's highest TAC, where, and its mean | +| `above(limit, enabled)` | `Above`: a mask of the pixels over `limit` and their `count`; `regions()` lists connected regions with bounding boxes, largest first (at most `MAX_REGIONS`); `paint(rgb, color)` marks them on a preview | + +## Composite preview + +`Plates::composite(enabled, &cms)` returns sRGB8 (three bytes per pixel, row-major) of the +enabled inks: + +- the process plates convert through `cms` (a `CmsConversion` for the plates' printing + condition, `Plates::conversion` names it), relative colorimetric with black point + compensation, or through the bundled CMYK profile when `cms` can't convert; +- each enabled spot plate multiplies over them by the spot's appearance at its tint: a Lab + alternate interpolated from paper white to the solid, or a CMYK alternate scaled by the tint. + +Overprint is already in the plates (the renderer decides per plate what overprints and what +knocks out), so the preview shows it without rewriting any paint as a blend mode. + +```rust +use craft_prepress::{CmsConversion, InkId}; + +let cms = CmsConversion::fogra39(); +let mut enabled = vec![true; p.inks.len()]; +enabled[InkId::process(craft_prepress::Process::Black).index()] = false; // hide Black +let mut rgb = p.composite(&enabled, &cms); +let over = p.above(300.0, &enabled); +over.paint(&mut rgb, [255, 0, 255]); // TAC warning +let worst = over.regions().first().copied(); +``` + +## Ink manager + +`InkSet` carries the job's ink settings next to its inks: + +| Call | Effect | +|---|---| +| `alias(from, to)` | Spot `from` prints on `to`'s plate (a process ink or another spot, chains followed); `alias(from, from)` removes it. Loops and process `from` are refused. | +| `set_to_process(ink, on)` | Spot `ink` prints as process color, from its alternate (`spot_cmyk`). | +| `set_all_to_process(on)` | All Spots to Process; aliases still apply first. | +| `plate_of(ink)` | The plate a tint of `ink` lands on, `None` when it prints as process. | +| `resolve(&color, &cms)` | `color` with both applied. Tints landing on one plate combine as `1 − (1 − a)(1 − b)`; a converted color lists the four process inks first, and `InkColor::as_cmyk` gives the CMYK when no spot is left. | + +`resolve` takes a `CmykConversion` (such as `CmsConversion`) because a spot with a Lab +alternate needs the printing condition to become CMYK. + +Apply it in two places: + +- **Export.** The writer calls `resolve` on every color it emits, so the PDF carries the + result: DeviceCMYK when `as_cmyk` is `Some`, else a `Separation` or `DeviceN` of the + resolved inks (the aliased spot's name, not the original). An overprinting color that + became CMYK is written with `OPM 1`, so its zero components leave the plates under it as + they are. +- **Preview.** The preview separates that exported PDF with `plates` and shows + `composite` and the coverage readings. Plates are the truth: an ink manager change shows + after the next export, and the preview never recomputes overprint from the document. + +## In DesignCraft and VectorCraft + +- **Separations Preview**: export the spread to PDF + in memory, `plates(pdf, 0, dpi, &Options::default())` at the zoom's resolution, then + `composite(enabled, &cms)` for the canvas. The panel lists `p.inks` with a toggle per row + feeding `enabled`, each row's percentage from `coverage_at` under the pointer + (`pixel_at`), and the TAC mode from `above(limit, enabled)` with `paint`. Single-plate view + shows `p.plane(id)` as gray film. It would replace VectorCraft's `proof.rs`, which + recolors the document and draws overprint as Darken. +- **Ink Manager** (DesignCraft's `InkManager` aliases and convert-to-process, VectorCraft's + `inks.rs` plates): keep one `InkSet` per document, add each spot swatch with its Lab or + CMYK alternate, and map the dialog's rows onto `alias`, `set_to_process` and + `set_all_to_process`. The PDF writer resolves swatch colors through it; the Separations + Preview then lists the plates the export produced. +- **Preflight**: `stats(&[])` gives per-ink max and mean coverage and the page's highest TAC + and its position for a report line such as "TAC 340% at …, limit 300%". + +## Credits + +- Plate mode, ink discovery, the output-intent handling and the coverage readings and + composite (`Separations::above`, `stats`, `composite`) come from + [storytold/pdfcraft#2770](https://github.com/storytold/pdfcraft/pull/2770) by Guillem Hermida + (guillehr2), with craft-cms in place of moxcms. +- Knockout groups in the renderer come from + [storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410. + +Both MIT OR Apache-2.0, as this crate. diff --git a/crates/prepress/src/cms.rs b/crates/prepress/src/cms.rs new file mode 100644 index 00000000..103d861b --- /dev/null +++ b/crates/prepress/src/cms.rs @@ -0,0 +1,161 @@ +//! Process color conversion for plate mode through craft-cms. + +use std::sync::{Arc, OnceLock}; + +use craft_cms::{Builtin, ColorSpace, Intent, Profile, Transform, TransformOptions, builtin, cached, math}; +use craft_pdf::interpret::color::{CmykConversion, CmykSimulation}; + +/// Converts process color into a CMYK printing condition with craft-cms: relative +/// colorimetric, without black point compensation, as the plate renderer expects (see +/// [`CmykConversion`]). CMYK shows as sRGB relative colorimetric with black point +/// compensation. Transforms are built on first use and shared through craft-cms's cache. +pub struct CmsConversion { + profile: Profile, + perceptual: bool, + from_srgb: OnceLock>>, + from_lab: OnceLock>>, + to_srgb: OnceLock>>, +} + +/// Why a profile can't be a printing condition. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ProfileError { + #[error("the profile could not be read: {0}")] + Unreadable(String), + #[error("the profile is not CMYK")] + NotCmyk, + #[error("the profile has no PCS-to-device (BToA) tables")] + NoPcsToDevice, +} + +impl CmsConversion { + /// The printing condition of ICC profile bytes `icc`. + pub fn new(icc: &[u8]) -> Result { + Self::from_profile(Profile::parse(icc).map_err(|e| ProfileError::Unreadable(format!("{e:?}")))?) + } + + /// The printing condition of a parsed CMYK profile with PCS-to-device tables. + pub fn from_profile(profile: Profile) -> Result { + if profile.color_space != ColorSpace::Cmyk { + return Err(ProfileError::NotCmyk); + } + if profile.pcs_to_device(Intent::RelativeColorimetric).is_err() { + return Err(ProfileError::NoPcsToDevice); + } + // Without a colorimetric BToA1 table craft-cms uses BToA0, the perceptual one. + let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some(); + Ok(Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() }) + } + + /// craft-cms's Coated FOGRA39 profile. + pub fn fogra39() -> Self { + let profile = Builtin::CoatedFogra39.profile().clone(); + let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some(); + Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() } + } + + /// A plate-mode printing condition converting through this, for the profile bytes `icc` + /// this was made from. ICCBased CMYK colors embedding those same bytes separate as they + /// are. + pub fn simulation(self, icc: &[u8]) -> CmykSimulation { + CmykSimulation::with_conversion(icc, self) + } + + fn transform(cell: &OnceLock>>, src: &Profile, dst: &Profile, bpc: bool) -> Option> { + cell.get_or_init(|| { + // The fast path interpolates the same tables that 8-bit buffers use: plates convert + // images sample by sample through here. + let opts = TransformOptions { intent: Intent::RelativeColorimetric, bpc, precise_float: false }; + cached(src, dst, opts).ok() + }) + .clone() + } +} + +fn whole(input: &[f32], channels: usize, cmyk: &[f32]) -> bool { + input.len().is_multiple_of(channels) && cmyk.len().is_multiple_of(4) && input.len() / channels == cmyk.len() / 4 +} + +fn finite01(cmyk: &mut [f32]) { + for v in cmyk { + *v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + } +} + +impl CmykConversion for CmsConversion { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + if !whole(rgb, 3, cmyk) { + return None; + } + let t = Self::transform(&self.from_srgb, builtin::srgb(), &self.profile, false)?; + t.convert_f32(rgb, 3, cmyk, 4, false).ok()?; + finite01(cmyk); + Some(()) + } + + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + if !whole(xyz, 3, cmyk) { + return None; + } + // Into the Lab D50 identity profile's device values (ICC v4 Lab encoding). + let lab: Vec = xyz + .as_chunks::<3>() + .0 + .iter() + .flat_map(|c| { + let [l, a, b] = math::xyz_to_lab_f32(*c); + [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0] + }) + .collect(); + let t = Self::transform(&self.from_lab, Builtin::LabD50.profile(), &self.profile, false)?; + t.convert_f32(&lab, 3, cmyk, 4, false).ok()?; + finite01(cmyk); + Some(()) + } + + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if !cmyk.len().is_multiple_of(4) || cmyk.len() / 4 * 3 != rgb.len() { + return None; + } + let t = Self::transform(&self.to_srgb, &self.profile, builtin::srgb(), true)?; + t.convert_u8(cmyk, 4, rgb, 3, false).ok() + } + + fn description(&self) -> Option<&str> { + let d = self.profile.description.trim(); + (!d.is_empty()).then_some(d) + } + + fn perceptual(&self) -> bool { + self.perceptual + } +} + +/// CIE L\*a\*b\* (D50) of an sRGB color, relative colorimetric; `None` if craft-cms can't +/// convert. +pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { + static T: OnceLock>> = OnceLock::new(); + let t = CmsConversion::transform(&T, builtin::srgb(), Builtin::LabD50.profile(), false)?; + let mut lab = [0.0_f32; 3]; + let rgb = rgb.map(|c| f32::from(c) / 255.0); + t.convert_f32(&rgb, 3, &mut lab, 3, false).ok()?; + Some([lab[0] * 100.0, lab[1] * 255.0 - 128.0, lab[2] * 255.0 - 128.0]) +} + +/// sRGB of CIE L\*a\*b\* (D50) colors, relative colorimetric, three values per color in +/// both; `None` if craft-cms can't convert or the lengths disagree. +pub(crate) fn lab_to_srgb8(lab: &[f32], rgb: &mut [u8]) -> Option<()> { + static T: OnceLock>> = OnceLock::new(); + if !lab.len().is_multiple_of(3) || lab.len() != rgb.len() { + return None; + } + let t = CmsConversion::transform(&T, Builtin::LabD50.profile(), builtin::srgb(), false)?; + // The Lab D50 identity profile's device values (ICC v4 Lab encoding). + let encoded: Vec = lab.as_chunks::<3>().0.iter().flat_map(|[l, a, b]| [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]).collect(); + let mut out = vec![0.0_f32; encoded.len()]; + t.convert_f32(&encoded, 3, &mut out, 3, false).ok()?; + for (byte, v) in rgb.iter_mut().zip(out) { + *byte = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + } + Some(()) +} diff --git a/crates/prepress/src/composite.rs b/crates/prepress/src/composite.rs new file mode 100644 index 00000000..191eb0d8 --- /dev/null +++ b/crates/prepress/src/composite.rs @@ -0,0 +1,134 @@ +//! A color preview composed from plates, as a Separations Preview shows the page. +//! +//! The preview is built from the plates alone, after the renderer has decided per plate what +//! overprints and what knocks out, so it shows overprint as printed without changing any +//! paint's blend mode. + +use craft_pdf::interpret::color::{CmykConversion, cmyk_to_srgb8}; + +use crate::cms::lab_to_srgb8; +use crate::{Alternate, InkId, Plates, Process}; + +/// Pixels converted per call into the CMYK profile, to bound the scratch buffers. +const CHUNK: usize = 16 * 1024; + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +/// CMYK bytes to sRGB bytes through `cms`, else through the bundled profile pages are drawn +/// with; paper white if neither converts. +fn to_srgb(cms: &dyn CmykConversion, cmyk: &[u8], rgb: &mut [u8]) { + if cms.to_srgb8(cmyk, rgb).or_else(|| cmyk_to_srgb8(cmyk, rgb)).is_none() { + rgb.fill(255); + } +} + +/// Spot ink `alternate` alone at each tint byte, in sRGB: a Lab alternate runs in a straight +/// line from paper white (L\* 100) to the solid, a CMYK one scales its components, as a +/// `Separation` color space's usual tint transform does. +fn appearance(alternate: Alternate, cms: &dyn CmykConversion) -> Option> { + let mut rgb = vec![0_u8; 256 * 3]; + match alternate { + Alternate::Lab([l, a, b]) => { + let lab: Vec = (0..=255_u8) + .flat_map(|t| { + let t = f32::from(t) / 255.0; + [100.0 + t * (l - 100.0), t * a, t * b] + }) + .collect(); + lab_to_srgb8(&lab, &mut rgb)?; + } + Alternate::Cmyk(solid) => { + let cmyk: Vec = (0..=255_u8) + .flat_map(|t| solid.map(|c| (f32::from(t) * if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }).round() as u8)) + .collect(); + to_srgb(cms, &cmyk, &mut rgb); + } + } + Some(rgb.as_chunks::<3>().0.to_vec()) +} + +impl Plates { + /// The enabled inks as they print, as sRGB8 (three bytes per pixel, row-major, + /// `width × height` pixels). `enabled` is indexed by [`InkId::index`]; inks past its end + /// are enabled, so `&[]` shows every ink. + /// + /// The process plates convert through `cms` ([`CmsConversion`](crate::CmsConversion) for + /// the printing condition the plates were made for, see [`Plates::conversion`]), relative + /// colorimetric with black point compensation, or through the bundled CMYK profile when + /// `cms` can't. Each spot plate is then multiplied over them by the spot's appearance at + /// its tint, from its alternate. Spots whose Lab alternate craft-cms can't convert are + /// left out. + pub fn composite(&self, enabled: &[bool], cms: &dyn CmykConversion) -> Vec { + let n = (self.width as usize).saturating_mul(self.height as usize); + let mut rgb = vec![255_u8; n.saturating_mul(3)]; + let process: Vec> = Process::ALL + .iter() + .map(|p| { + let id = InkId::process(*p); + self.plane(id).filter(|_| on(enabled, id.index())) + }) + .collect(); + let mut cmyk = Vec::with_capacity(CHUNK * 4); + for (c, out) in rgb.chunks_mut(CHUNK * 3).enumerate() { + let start = c * CHUNK; + let len = out.len() / 3; + cmyk.clear(); + for i in start..start + len { + cmyk.extend(process.iter().map(|plane| plane.and_then(|p| p.get(i)).copied().unwrap_or(0))); + } + to_srgb(cms, &cmyk, out); + } + + for (id, spot) in self.inks.spots().filter(|(id, _)| on(enabled, id.index())) { + let Some(plane) = self.plane(id).filter(|p| p.iter().any(|t| *t > 0)) else { continue }; + let Some(ramp) = appearance(spot.alternate, cms) else { continue }; + for (px, tint) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(plane) { + if let Some(ink) = ramp.get(usize::from(*tint)).filter(|_| *tint > 0) { + for (c, s) in px.iter_mut().zip(ink) { + *c = ((u16::from(*c) * u16::from(*s) + 127) / 255) as u8; + } + } + } + } + rgb + } +} + +#[cfg(test)] +mod tests { + use crate::CmsConversion; + use crate::cms::srgb8_to_lab; + use crate::coverage::tests::synthetic; + + fn near(got: &[u8], expected: [u8; 3], tolerance: u8) -> bool { + got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= tolerance) + } + + #[test] + fn a_black_and_gold_composite_multiplies_gold_over_black() { + const GOLD: [u8; 3] = [212, 175, 55]; + let lab = srgb8_to_lab(GOLD).unwrap(); + // Black alone, Gold alone, Gold over black, Gold at 50%. + let p = synthetic(4, 1, lab, [vec![0; 4], vec![0; 4], vec![0; 4], vec![255, 0, 255, 0], vec![0, 255, 255, 128]]); + let cms = CmsConversion::fogra39(); + let rgb = p.composite(&[], &cms); + assert_eq!(rgb.len(), 12); + + let black = &rgb[0..3]; + let (lo, hi) = (black.iter().min().unwrap(), black.iter().max().unwrap()); + assert!(*hi < 80 && hi - lo < 12, "Coated FOGRA39 black is a dark neutral: {black:?}"); + assert!(near(&rgb[3..6], GOLD, 2), "Gold: {:?}", &rgb[3..6]); + let over: Vec = black.iter().zip(GOLD).map(|(k, g)| ((u16::from(*k) * u16::from(g) + 127) / 255) as u8).collect(); + assert!(near(&rgb[6..9], [over[0], over[1], over[2]], 2), "Gold over black: {:?}", &rgb[6..9]); + let half = &rgb[9..12]; + assert!(half.iter().zip(GOLD).all(|(h, g)| *h > g && *h < 255) && half[2] > 110 && half[2] < 200, "Gold at 50%: {half:?}"); + + let no_black = p.composite(&[true, true, true, false], &cms); + assert!(near(&no_black[0..3], [255, 255, 255], 1), "paper: {:?}", &no_black[0..3]); + assert!(near(&no_black[6..9], GOLD, 2)); + let no_gold = p.composite(&[true, true, true, true, false], &cms); + assert_eq!(&no_gold[6..9], black); + } +} diff --git a/crates/prepress/src/coverage.rs b/crates/prepress/src/coverage.rs new file mode 100644 index 00000000..d88cad9a --- /dev/null +++ b/crates/prepress/src/coverage.rs @@ -0,0 +1,295 @@ +//! Ink coverage read from plates: per ink at a point and over a page, total area coverage +//! (TAC) per pixel, and the pixels over a TAC limit. +//! +//! Inks are chosen with an `enabled` list indexed by [`InkId::index`], as a Separations +//! Preview's toggles hold them: inks past its end are enabled, so `&[]` enables every ink. +//! Percentages are of one ink's 100%, so a TAC runs to 100 × the number of inks. + +use crate::{InkId, Plates}; + +/// The most regions [`Above::regions`] lists. +pub const MAX_REGIONS: usize = 256; + +/// Coverage at one pixel, in percent. +#[derive(Clone, Debug, PartialEq)] +pub struct Coverage { + /// Each ink's tint, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The total area coverage: the sum of the enabled inks' tints. + pub total: f32, +} + +/// One ink's coverage over a page, in percent. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct InkStats { + /// The highest tint. + pub max: f32, + /// The mean tint over every pixel of the page. + pub mean: f32, + /// The share of the page the ink prints on (any tint above 0). + pub area: f32, +} + +/// A page's coverage figures, in percent. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Stats { + /// Each ink's figures, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The highest TAC of the enabled inks. + pub max_total: f32, + /// The first pixel (in row order) with [`max_total`](Self::max_total); `None` for an empty + /// page. + pub max_total_at: Option<(u32, u32)>, + /// The mean TAC of the enabled inks. + pub mean_total: f32, +} + +/// The pixels whose TAC is over a limit. +#[derive(Clone, Debug, PartialEq)] +pub struct Above { + pub width: u32, + pub height: u32, + /// One entry per pixel, row-major: `true` where the TAC is over the limit. + pub mask: Vec, + /// The number of pixels over the limit. + pub count: usize, +} + +/// A connected group of pixels over the limit (4-connected), with its bounding box in pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Region { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + /// The pixels over the limit in the region. + pub pixels: u32, +} + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +fn percent(tint: u64) -> f32 { + (tint as f64 * 100.0 / 255.0) as f32 +} + +impl Plates { + fn pixels(&self) -> usize { + (self.width as usize).saturating_mul(self.height as usize) + } + + fn index_of(&self, x: u32, y: u32) -> Option { + (x < self.width && y < self.height).then(|| y as usize * self.width as usize + x as usize) + } + + fn enabled_planes<'a>(&'a self, enabled: &'a [bool]) -> impl Iterator> + 'a { + self.planes.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, p)| p) + } + + /// The pixel under a point given as fractions `0.0..1.0` of the page's width and height, + /// as a pointer over a preview gives it. + pub fn pixel_at(&self, u: f32, v: f32) -> Option<(u32, u32)> { + if !(0.0..1.0).contains(&u) || !(0.0..1.0).contains(&v) { + return None; + } + let x = ((u * self.width as f32) as u32).min(self.width.checked_sub(1)?); + let y = ((v * self.height as f32) as u32).min(self.height.checked_sub(1)?); + Some((x, y)) + } + + /// Each ink's tint and the TAC of the enabled inks at pixel (`x`, `y`). + pub fn coverage_at(&self, x: u32, y: u32, enabled: &[bool]) -> Option { + let at = self.index_of(x, y)?; + let tints: Vec = self.planes.iter().map(|p| p.get(at).copied().map_or(0, u64::from)).collect(); + let total = tints.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, t)| t).sum(); + Some(Coverage { inks: tints.into_iter().map(percent).collect(), total: percent(total) }) + } + + /// Ink `id`'s tint at pixel (`x`, `y`) in percent. + pub fn percent_at(&self, id: InkId, x: u32, y: u32) -> Option { + self.tint(id, x, y).map(|t| percent(u64::from(t))) + } + + /// The TAC of the enabled inks at every pixel, row-major, as a sum of tint bytes (255 per + /// ink at 100%). + fn totals(&self, enabled: &[bool]) -> Vec { + let mut totals = vec![0_u32; self.pixels()]; + for plane in self.enabled_planes(enabled) { + for (sum, tint) in totals.iter_mut().zip(plane) { + *sum = sum.saturating_add(u32::from(*tint)); + } + } + totals + } + + /// The TAC of the enabled inks at every pixel, row-major, in percent. + pub fn tac(&self, enabled: &[bool]) -> Vec { + self.totals(enabled).into_iter().map(|t| percent(u64::from(t))).collect() + } + + /// Each ink's figures over the page, and the TAC of the enabled inks. + pub fn stats(&self, enabled: &[bool]) -> Stats { + let n = self.pixels(); + let mean = |sum: u64| if n == 0 { 0.0 } else { percent(sum) / n as f32 }; + let inks = self + .planes + .iter() + .map(|plane| { + let plane = plane.get(..n).unwrap_or(plane); + let max = plane.iter().copied().max().unwrap_or(0); + let sum: u64 = plane.iter().map(|t| u64::from(*t)).sum(); + let inked = plane.iter().filter(|t| **t > 0).count(); + let area = if n == 0 { 0.0 } else { inked as f32 * 100.0 / n as f32 }; + InkStats { max: percent(u64::from(max)), mean: mean(sum), area } + }) + .collect(); + let totals = self.totals(enabled); + // The first pixel with the highest total: `max_by_key` keeps the last of equals. + let top = totals.iter().enumerate().rev().max_by_key(|(_, t)| **t); + let width = self.width as usize; + let max_total_at = top.and_then(|(at, _)| Some((u32::try_from(at % width).ok()?, u32::try_from(at / width).ok()?))); + Stats { + inks, + max_total: top.map_or(0.0, |(_, t)| percent(u64::from(*t))), + max_total_at, + mean_total: mean(totals.iter().map(|t| u64::from(*t)).sum()), + } + } + + /// The pixels whose TAC of the enabled inks is over `limit` percent (such as 300 for + /// coated paper). A limit that isn't finite marks nothing. + pub fn above(&self, limit: f32, enabled: &[bool]) -> Above { + let limit = if limit.is_finite() { limit.max(0.0) } else { f32::MAX }; + let mask: Vec = self.totals(enabled).into_iter().map(|t| percent(u64::from(t)) > limit).collect(); + let count = mask.iter().filter(|m| **m).count(); + Above { width: self.width, height: self.height, mask, count } + } +} + +impl Above { + /// The connected regions of marked pixels, largest first (ties in row order of their first + /// pixel), at most [`MAX_REGIONS`] of them. + pub fn regions(&self) -> Vec { + let (w, h) = (self.width as usize, self.height as usize); + if self.mask.len() != w.saturating_mul(h) { + return Vec::new(); + } + let mut seen = vec![false; self.mask.len()]; + let mut regions = Vec::new(); + let mut stack = Vec::new(); + for start in 0..self.mask.len() { + if !self.mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let (mut x0, mut y0, mut x1, mut y1, mut pixels) = (start % w, start / w, start % w, start / w, 0_u32); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + stack.push(start); + while let Some(at) = stack.pop() { + let (x, y) = (at % w, at / w); + pixels = pixels.saturating_add(1); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + let next = [(x > 0).then(|| at - 1), (x + 1 < w).then(|| at + 1), (y > 0).then(|| at - w), (y + 1 < h).then(|| at + w)]; + for n in next.into_iter().flatten() { + if self.mask.get(n).copied().unwrap_or(false) + && let Some(s) = seen.get_mut(n).filter(|s| !**s) + { + *s = true; + stack.push(n); + } + } + } + // The mask is at most `width × height` with both `u32`, so coordinates fit. + regions.push(Region { x: x0 as u32, y: y0 as u32, width: (x1 - x0 + 1) as u32, height: (y1 - y0 + 1) as u32, pixels }); + } + regions.sort_by_key(|r| std::cmp::Reverse(r.pixels)); + regions.truncate(MAX_REGIONS); + regions + } + + /// Paints the marked pixels of an sRGB8 image of the same size (three bytes per pixel, + /// such as [`Plates::composite`]'s) in `color`, as a TAC warning. + pub fn paint(&self, rgb: &mut [u8], color: [u8; 3]) { + for (px, _) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(&self.mask).filter(|(_, m)| **m) { + *px = color; + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{Alternate, Conversion, Ink, InkSet, Spot}; + + /// Plates of `width × height` pixels for the process inks and a Gold spot (Lab alternate + /// `gold`), from planes given in `C M Y K Gold` order. + pub(crate) fn synthetic(width: u32, height: u32, gold: [f32; 3], planes: [Vec; 5]) -> Plates { + let mut inks = InkSet::new(); + inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Lab(gold), solidity: None })); + Plates { inks, unseparated: Vec::new(), width, height, dpi: 72.0, conversion: Conversion::DeviceFormulas, planes: planes.to_vec() } + } + + fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 + } + + #[test] + fn coverage_tac_and_regions_over_a_limit() { + // 3 × 2 pixels: rich black with Gold at (0,0), (1,0) (Gold at 50%) and (2,1); black + // alone at (2,0). + let p = synthetic( + 3, + 2, + [70.0, 5.0, 60.0], + [vec![0; 6], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 255, 0, 0, 255], vec![255, 128, 0, 0, 0, 255]], + ); + let at = p.coverage_at(1, 0, &[]).unwrap(); + assert!(close(at.inks[4], 50.196) && close(at.total, 350.196), "{at:?}"); + assert!(close(p.coverage_at(1, 0, &[true, true, true, true, false]).unwrap().total, 300.0), "Gold hidden"); + assert_eq!(p.coverage_at(3, 0, &[]), None); + assert_eq!(p.pixel_at(0.99, 0.6), Some((2, 1))); + assert_eq!(p.pixel_at(1.0, 0.0), None); + assert!(close(p.tac(&[])[2], 100.0)); + + let s = p.stats(&[]); + assert!(close(s.max_total, 400.0) && s.max_total_at == Some((0, 0)), "{s:?}"); + assert!(close(s.mean_total, 3188.0 / 1530.0 * 100.0), "{s:?}"); + let k = s.inks[3]; + assert!(close(k.max, 100.0) && close(k.mean, 66.667) && close(k.area, 66.667), "{k:?}"); + let gold = s.inks[4]; + assert!(close(gold.mean, 638.0 / 1530.0 * 100.0) && close(gold.area, 50.0), "{gold:?}"); + assert_eq!(s.inks[0], InkStats::default()); + + let over = p.above(300.0, &[]); + assert_eq!(over.count, 3); + assert_eq!(over.mask, [true, true, false, false, false, true]); + let regions = over.regions(); + assert_eq!(regions, [Region { x: 0, y: 0, width: 2, height: 1, pixels: 2 }, Region { x: 2, y: 1, width: 1, height: 1, pixels: 1 }]); + assert_eq!(p.above(300.0, &[true, true, true, true, false]).count, 0, "exactly at the limit is not over it"); + assert_eq!(p.above(f32::NAN, &[]).count, 0); + + let mut rgb = vec![0_u8; 18]; + over.paint(&mut rgb, [255, 0, 255]); + assert_eq!(&rgb[..6], [255, 0, 255, 255, 0, 255]); + assert_eq!(&rgb[6..9], [0, 0, 0]); + } + + #[test] + fn regions_join_four_connected_pixels_and_keep_the_largest() { + // A ring around an unmarked center is one region; the diagonal neighbor is another. + #[rustfmt::skip] + let mask = vec![ + true, true, true, false, + true, false, true, false, + true, true, true, false, + false, false, false, true, + ]; + let above = Above { width: 4, height: 4, count: 9, mask }; + assert_eq!(above.regions(), [Region { x: 0, y: 0, width: 3, height: 3, pixels: 8 }, Region { x: 3, y: 3, width: 1, height: 1, pixels: 1 }]); + let empty = Plates { width: 0, height: 0, planes: Vec::new(), ..synthetic(0, 0, [0.0; 3], Default::default()) }; + assert_eq!(empty.stats(&[]).max_total_at, None); + assert!(empty.above(10.0, &[]).regions().is_empty()); + } +} diff --git a/crates/prepress/src/ink.rs b/crates/prepress/src/ink.rs new file mode 100644 index 00000000..b023b1cf --- /dev/null +++ b/crates/prepress/src/ink.rs @@ -0,0 +1,485 @@ +//! The ink model: which inks a job prints with, and colors as tints of those inks. + +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + +use craft_cms::math; +use craft_pdf::interpret::color::CmykConversion; + +use crate::cms::lab_to_srgb8; + +/// A process ink, in plate order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Process { + Cyan, + Magenta, + Yellow, + Black, +} + +impl Process { + /// The four process inks, in plate order. + pub const ALL: [Process; 4] = [Process::Cyan, Process::Magenta, Process::Yellow, Process::Black]; + + /// The colorant name a `Separation` or `DeviceN` color space gives this ink. + pub fn name(self) -> &'static str { + match self { + Self::Cyan => "Cyan", + Self::Magenta => "Magenta", + Self::Yellow => "Yellow", + Self::Black => "Black", + } + } + + /// The process ink a colorant name denotes (ISO 32000-2 §8.6.6.4). + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|p| p.name() == name) + } + + /// The ink's position in a CMYK color. + pub fn index(self) -> usize { + self as usize + } +} + +/// How a spot ink looks, for previews and for printing it as process color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Alternate { + /// CMYK components, `0.0..=1.0`. + Cmyk([f32; 4]), + /// CIE L\*a\*b\* relative to D50. + Lab([f32; 3]), +} + +/// A spot ink. +#[derive(Clone, Debug, PartialEq)] +pub struct Spot { + /// The colorant name, as a `Separation` color space would name it, without PDF name + /// escapes (see [`normalize_name`]). + pub name: String, + /// The ink at 100%. + pub alternate: Alternate, + /// How opaque the ink is (`0.0..=1.0`, as InDesign's Ink Manager states it), when known. + pub solidity: Option, +} + +/// A process or spot ink. +#[derive(Clone, Debug, PartialEq)] +pub enum Ink { + Process(Process), + Spot(Spot), +} + +impl Ink { + /// The colorant name: `Cyan` … `Black`, or the spot ink's name. + pub fn name(&self) -> &str { + match self { + Self::Process(p) => p.name(), + Self::Spot(s) => &s.name, + } + } + + pub fn is_spot(&self) -> bool { + matches!(self, Self::Spot(_)) + } +} + +/// An ink's index in its [`InkSet`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct InkId(u16); + +impl InkId { + /// The ink's position in [`InkSet::iter`] order, which is also its plane in + /// [`Plates::planes`](crate::Plates::planes). + pub fn index(self) -> usize { + usize::from(self.0) + } + + /// The id of process ink `p`, the same in every [`InkSet`]. + pub fn process(p: Process) -> Self { + Self(p as u16) + } +} + +/// The inks of one document or job: the four process inks, always first and in plate order, +/// then spot inks in the order added. Ids stay valid as inks are added. +/// +/// The set is also the job's ink manager, as InDesign's Ink Manager states it: a spot ink can +/// print on another ink's plate ([`alias`](Self::alias)) or as process color +/// ([`set_to_process`](Self::set_to_process), [`set_all_to_process`](Self::set_all_to_process)). +/// [`resolve`](Self::resolve) applies both to a color. +#[derive(Clone, Debug, PartialEq)] +pub struct InkSet { + inks: Vec, + aliases: BTreeMap, + to_process: BTreeSet, + all_to_process: bool, +} + +impl Default for InkSet { + fn default() -> Self { + Self::new() + } +} + +impl InkSet { + /// The four process inks. + pub fn new() -> Self { + Self { + inks: Process::ALL.into_iter().map(Ink::Process).collect(), + aliases: BTreeMap::new(), + to_process: BTreeSet::new(), + all_to_process: false, + } + } + + /// Adds `ink` and returns its id. An ink whose name is already in the set (compared after + /// [`normalize_name`]) is not added again, and its existing id is returned; a spot named + /// `Cyan` … `Black` is the process ink. A spot's name is stored normalized. `None` for the + /// names `All` and `None`, which are not inks (§8.6.6.4), and when the set is full + /// (65 536 inks). + pub fn add(&mut self, ink: Ink) -> Option { + let spot = match ink { + Ink::Process(p) => return Some(InkId::process(p)), + Ink::Spot(spot) => spot, + }; + let name = normalize_name(&spot.name).into_owned(); + if name == "All" || name == "None" { + return None; + } + if let Some(id) = self.lookup(&name) { + return Some(id); + } + let id = InkId(u16::try_from(self.inks.len()).ok()?); + self.inks.push(Ink::Spot(Spot { name, ..spot })); + Some(id) + } + + /// The ink named `name`, compared after [`normalize_name`]: `"/Gold#20Foil"` finds + /// `Gold Foil`. + pub fn lookup(&self, name: &str) -> Option { + let name = normalize_name(name); + if let Some(p) = Process::from_name(&name) { + return Some(InkId::process(p)); + } + let at = self.inks.iter().skip(4).position(|ink| ink.name() == name)?; + u16::try_from(at + 4).ok().map(InkId) + } + + pub fn get(&self, id: InkId) -> Option<&Ink> { + self.inks.get(id.index()) + } + + /// Every ink with its id: the process inks, then the spot inks. + pub fn iter(&self) -> impl Iterator { + // At most 65 536 inks (`add`), so every index fits. + self.inks.iter().enumerate().map(|(i, ink)| (InkId(i as u16), ink)) + } + + /// The spot inks with their ids. + pub fn spots(&self) -> impl Iterator { + self.iter().filter_map(|(id, ink)| match ink { + Ink::Spot(s) => Some((id, s)), + Ink::Process(_) => None, + }) + } + + /// The number of inks, process inks included. + pub fn len(&self) -> usize { + self.inks.len() + } + + /// Always `false`: a set holds the process inks. + pub fn is_empty(&self) -> bool { + self.inks.is_empty() + } + + fn spot(&self, id: InkId) -> Option<&Spot> { + match self.get(id)? { + Ink::Spot(s) => Some(s), + Ink::Process(_) => None, + } + } + + /// Prints spot ink `from` on ink `to`'s plate. `to` may be a process ink or another spot, + /// which may itself be aliased or printed as process color; `resolve` follows the chain. + /// `to == from` removes the alias. Returns `false`, changing nothing, when `from` is not a + /// spot ink of the set, `to` is not in the set, or the alias would make a loop. + pub fn alias(&mut self, from: InkId, to: InkId) -> bool { + if self.spot(from).is_none() || self.get(to).is_none() { + return false; + } + if from == to { + self.aliases.remove(&from); + return true; + } + if self.plate_chain(to).any(|id| id == from) { + return false; + } + self.aliases.insert(from, to); + true + } + + /// The ink spot ink `from` is aliased to, if any. + pub fn alias_of(&self, from: InkId) -> Option { + self.aliases.get(&from).copied() + } + + /// Prints spot ink `ink` as process color (`on`), converted from its alternate, or on its + /// own plate. Returns `false`, changing nothing, when `ink` is not a spot ink of the set. + pub fn set_to_process(&mut self, ink: InkId, on: bool) -> bool { + if self.spot(ink).is_none() { + return false; + } + if on { + self.to_process.insert(ink); + } else { + self.to_process.remove(&ink); + } + true + } + + /// Prints every spot ink as process color ("All Spots to Process"), whatever + /// [`set_to_process`](Self::set_to_process) says for each. Aliases still apply first: a + /// spot aliased to a process ink prints on that ink's plate. + pub fn set_all_to_process(&mut self, on: bool) { + self.all_to_process = on; + } + + pub fn all_to_process(&self) -> bool { + self.all_to_process + } + + /// Whether spot ink `ink` prints as process color when a color names it directly (an + /// alias of it is followed first, see [`plate_of`](Self::plate_of)). + pub fn is_to_process(&self, ink: InkId) -> bool { + self.spot(ink).is_some() && (self.all_to_process || self.to_process.contains(&ink)) + } + + /// `id`, then each ink it is aliased to in turn. Stops after as many steps as there are + /// inks, so a loop can't make it endless. + fn plate_chain(&self, id: InkId) -> impl Iterator + '_ { + std::iter::successors(Some(id), |id| self.alias_of(*id)).take(self.inks.len()) + } + + /// The ink whose plate a tint of `id` prints on after aliases: `id` itself, the end of its + /// alias chain, or `None` when that ink prints as process color (or `id` is not in the + /// set). + pub fn plate_of(&self, id: InkId) -> Option { + self.get(id)?; + let end = self.plate_chain(id).last()?; + (!self.is_to_process(end)).then_some(end) + } + + /// Spot ink `id` at 100% as process color, `0.0..=1.0` per component: its CMYK alternate + /// as it is, a Lab alternate through `cms`, or, when `cms` can't convert, through sRGB and + /// the ISO 32000-2 §10.4.2.4 formulas (full black generation and undercolor removal). + /// `None` for process inks, inks not in the set, and Lab colors craft-cms can't convert. + pub fn spot_cmyk(&self, id: InkId, cms: &dyn CmykConversion) -> Option<[f32; 4]> { + let lab = match self.spot(id)?.alternate { + Alternate::Cmyk(cmyk) => return Some(cmyk.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })), + Alternate::Lab(lab) => lab, + }; + let mut cmyk = [0.0_f32; 4]; + if cms.from_pcs_xyz(&math::lab_to_xyz_f32(lab), &mut cmyk).is_some() { + return Some(cmyk); + } + let mut rgb = [0_u8; 3]; + lab_to_srgb8(&lab, &mut rgb)?; + let [c, m, y] = rgb.map(|v| 1.0 - f32::from(v) / 255.0); + let k = c.min(m).min(y); + Some([c - k, m - k, y - k, k]) + } + + /// `color` as it prints with this set's aliases and to-process settings: each tint moves + /// to the plate [`plate_of`](Self::plate_of) names, and a tint of an ink printed as process + /// color becomes that tint of its [`spot_cmyk`](Self::spot_cmyk). Tints that land on one + /// plate combine as overlapping halftones, `1 − (1 − a)(1 − b)`. When any tint became + /// process color the result lists all four process inks first (a CMYK color if no spot + /// is left); otherwise inks keep their order. Inks not in the set, and spots whose Lab + /// alternate can't be converted, stay as they are. `All` and `None` are unchanged. + pub fn resolve(&self, color: &InkColor, cms: &dyn CmykConversion) -> InkColor { + let InkColor::Tints(tints) = color else { return color.clone() }; + let mut out: Vec<(InkId, f32)> = Vec::with_capacity(tints.len()); + let mut process: Option<[f32; 4]> = None; + let combine = |a: f32, b: f32| 1.0 - (1.0 - a) * (1.0 - b); + for &(id, tint) in tints { + let end = match self.get(id) { + Some(_) => self.plate_chain(id).last().unwrap_or(id), + None => id, + }; + if self.is_to_process(end) + && let Some(cmyk) = self.spot_cmyk(end, cms) + { + let acc = process.get_or_insert([0.0; 4]); + for (a, c) in acc.iter_mut().zip(cmyk) { + *a = combine(*a, c * tint); + } + continue; + } + match out.iter_mut().find(|(i, _)| *i == end) { + Some((_, t)) => *t = combine(*t, tint), + None => out.push((end, tint)), + } + } + let Some(mut cmyk) = process else { return InkColor::Tints(out) }; + out.retain(|(id, tint)| match Process::ALL.get(id.index()) { + Some(p) => { + if let Some(c) = cmyk.get_mut(p.index()) { + *c = combine(*c, *tint); + } + false + } + None => true, + }); + let mut all: Vec<(InkId, f32)> = Process::ALL.iter().zip(cmyk).map(|(p, c)| (InkId::process(*p), c)).collect(); + all.extend(out); + InkColor::Tints(all) + } +} + +/// A color as ink tints. +#[derive(Clone, Debug, PartialEq)] +pub enum InkColor { + /// Tints `0.0..=1.0` of the listed inks; other inks are unspecified (they keep what is + /// under the paint when it overprints, and are knocked out otherwise). One entry is a + /// `Separation` color, several a `DeviceN` one. + Tints(Vec<(InkId, f32)>), + /// The same tint on every plate: the registration color (the `All` colorant). + All(f32), + /// No ink on any plate (the `None` colorant). + None, +} + +impl InkColor { + /// The color as DeviceCMYK components when every tint is of a process ink, as + /// [`InkSet::resolve`] leaves a spot printed as process color; process inks not listed + /// are 0. `None` when a spot ink is listed, and for `All` and `None`. + pub fn as_cmyk(&self) -> Option<[f32; 4]> { + let Self::Tints(tints) = self else { return None }; + let mut cmyk = [0.0; 4]; + for (id, tint) in tints { + *cmyk.get_mut(id.index())? = *tint; + } + Some(cmyk) + } +} + +/// Overprint parameters of a paint (ISO 32000-2 §8.6.7). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Overprint { + /// Fills and other non-stroking paints overprint (`op`). + pub fill: bool, + /// Strokes overprint (`OP`). + pub stroke: bool, + /// Nonzero overprint mode (`OPM 1`): a zero DeviceCMYK component leaves its plate as it is. + pub nonzero: bool, +} + +/// A colorant name as a PDF name object carries it: without a leading `/` and with `#xx` +/// escapes decoded (ISO 32000-2 §7.3.5), read as UTF-8 (invalid sequences replaced). +pub fn normalize_name(name: &str) -> Cow<'_, str> { + let name = name.strip_prefix('/').unwrap_or(name); + if !name.contains('#') { + return Cow::Borrowed(name); + } + let bytes = name.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match (b, hex) { + (b'#', Some(decoded)) => { + out.push(decoded); + i += 3; + } + _ => { + out.push(b); + i += 1; + } + } + } + Cow::Owned(String::from_utf8_lossy(&out).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spot(name: &str) -> Ink { + Ink::Spot(Spot { name: name.into(), alternate: Alternate::Lab([80.0, 5.0, 60.0]), solidity: None }) + } + + #[test] + fn spots_follow_the_process_inks_and_names_are_compared_as_pdf_names() { + let mut set = InkSet::new(); + let gold = set.add(spot("/Gold#20Foil")).unwrap(); + assert_eq!(gold.index(), 4); + assert_eq!(set.get(gold).unwrap().name(), "Gold Foil"); + assert_eq!(set.add(spot("Gold Foil")), Some(gold), "added once"); + assert_eq!(set.lookup("Gold#20Foil"), Some(gold)); + assert_eq!(set.add(spot("Black")), Some(InkId::process(Process::Black))); + assert_eq!(set.lookup("/Cyan"), Some(InkId::process(Process::Cyan))); + assert_eq!(set.add(spot("All")), None); + assert_eq!(set.add(spot("None")), None); + assert_eq!(set.lookup("gold foil"), None, "names are case-sensitive"); + let names: Vec<&str> = set.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold Foil"]); + assert_eq!(set.spots().count(), 1); + } + + fn close(got: Option<[f32; 4]>, expected: [f32; 4]) -> bool { + got.is_some_and(|got| got.iter().zip(expected).all(|(g, e)| (g - e).abs() < 1e-4)) + } + + #[test] + fn aliases_and_spots_to_process_resolve_colors() { + let mut set = InkSet::new(); + let gold = set.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap(); + let yellow = set.add(spot("Lab Yellow")).unwrap(); + let varnish = set.add(spot("Varnish")).unwrap(); + let k = InkId::process(Process::Black); + let cms = crate::CmsConversion::fogra39(); + let tints = |t: &[(InkId, f32)]| InkColor::Tints(t.to_vec()); + + let plain = tints(&[(gold, 0.5), (k, 1.0)]); + assert_eq!(set.resolve(&plain, &cms), plain, "no settings: unchanged"); + assert_eq!(set.resolve(&InkColor::All(1.0), &cms), InkColor::All(1.0)); + + assert!(set.alias(varnish, gold)); + assert_eq!((set.alias_of(varnish), set.plate_of(varnish)), (Some(gold), Some(gold))); + assert_eq!(set.resolve(&tints(&[(varnish, 0.5)]), &cms), tints(&[(gold, 0.5)])); + assert_eq!(set.resolve(&tints(&[(gold, 0.5), (varnish, 0.5)]), &cms), tints(&[(gold, 0.75)]), "one plate: tints overlap"); + assert!(!set.alias(gold, varnish), "a loop is refused"); + assert!(!set.alias(k, gold), "process inks aren't aliased"); + assert!(!set.alias(gold, InkId(99))); + + assert!(set.set_to_process(gold, true)); + assert!(!set.set_to_process(k, true)); + assert_eq!(set.plate_of(varnish), None, "aliased to a spot printed as process"); + assert!(close(set.resolve(&tints(&[(varnish, 0.5)]), &cms).as_cmyk(), [0.0, 0.15, 0.5, 0.0])); + let rich = set.resolve(&tints(&[(k, 0.5), (gold, 1.0)]), &cms); + assert!(close(rich.as_cmyk(), [0.0, 0.3, 1.0, 0.5]), "{rich:?}"); + let InkColor::Tints(mixed) = set.resolve(&tints(&[(yellow, 0.4), (gold, 1.0)]), &cms) else { panic!() }; + let order: Vec = mixed.iter().map(|(id, _)| *id).collect(); + assert_eq!(order[..4], Process::ALL.map(InkId::process), "process inks first"); + assert_eq!(mixed[4], (yellow, 0.4), "the spot left on its plate"); + + set.set_all_to_process(true); + assert!(set.is_to_process(yellow) && !set.is_to_process(k)); + let lab = set.resolve(&tints(&[(yellow, 1.0)]), &cms).as_cmyk().unwrap(); + assert!(lab[2] > 0.4 && lab[2] > lab[0] && lab[3] < 0.2, "a light yellow through FOGRA39: {lab:?}"); + assert!(set.alias(yellow, k)); + assert_eq!(set.resolve(&tints(&[(yellow, 0.5)]), &cms), tints(&[(k, 0.5)]), "aliases apply first"); + + assert!(set.alias(varnish, varnish)); + assert_eq!(set.alias_of(varnish), None); + } + + #[test] + fn malformed_escapes_stay_as_written() { + assert_eq!(normalize_name("A#2"), "A#2"); + assert_eq!(normalize_name("A#zz#41"), "A#zzA"); + assert_eq!(normalize_name("#E2#82#AC"), "€"); + } +} diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs new file mode 100644 index 00000000..3543f633 --- /dev/null +++ b/crates/prepress/src/lib.rs @@ -0,0 +1,16 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Prepress for the Craft apps: an ink model and ink manager shared by the writers, +//! plates rendered from a PDF, coverage and a color preview read from the plates, and process +//! color conversion through craft-cms. See the README. + +pub mod cms; +pub mod composite; +pub mod coverage; +pub mod ink; +pub mod plates; + +pub use cms::{CmsConversion, ProfileError}; +pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; +pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name}; +pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/prepress/src/plates.rs b/crates/prepress/src/plates.rs new file mode 100644 index 00000000..ea7ebd25 --- /dev/null +++ b/crates/prepress/src/plates.rs @@ -0,0 +1,320 @@ +//! A PDF page separated into plates, one gray film per ink. +//! +//! A plate is the page rendered with hayro-interpret's plate mode +//! (`InterpreterSettings::plate`): every paint draws its tint of one ink as gray, and a paint +//! that overprints without naming the ink leaves the film as it is (ISO 32000-2 §8.6.7). +//! Clipping, anti-aliasing, soft masks, transparency groups and blending stay the renderer's +//! own. `Separation` and `DeviceN` colorants paint their own plates, DeviceCMYK its four +//! components, DeviceGray black only, and other process color converts to CMYK through the +//! printing condition (see [`Conversion`]). +//! +//! Ported from PdfCraft #2770 by Guillem Hermida (`crates/render/src/separations.rs` at +//! 6fcf2a4, MIT OR Apache-2.0), with craft-cms in place of moxcms. + +use std::collections::HashSet; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, Mutex}; + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::interpret::color::{CmykSimulation, Ink as PdfInk, InkSinkFn, Plate}; +use craft_pdf::raster::{self, MAX_SIDE, RenderCache}; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::dict::keys::{DEST_OUTPUT_PROFILE, OUTPUT_CONDITION, OUTPUT_CONDITION_IDENTIFIER, OUTPUT_INTENTS, S}; +use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfText}; +use craft_pdf::{OpenError, open_slice}; + +use crate::cms::{CmsConversion, ProfileError, srgb8_to_lab}; +use crate::ink::{Alternate, Ink, InkId, InkSet, Spot}; + +/// Spot inks separated per page, after the four process inks. More than any press runs: a +/// `DeviceN` space has at most 32 colorants, and every plate costs a render and a byte per +/// pixel. Further spot inks are listed in [`Plates::unseparated`]. +pub const MAX_SPOT_INKS: usize = 32; + +/// Device pixels per plate (4 MP, 2000 × 2000), so a page with many inks stays within a few +/// hundred megabytes. The resolution is reduced to fit; [`Plates::dpi`] says what was used. +pub const MAX_PLATE_PIXELS: f32 = 4.0e6; + +/// The resolution, in pixels per point, inks are discovered at before the plates render. +const DISCOVERY_SCALE: f32 = 0.1; + +/// Spot ink names recorded while discovering, past which further names are dropped. +const MAX_LISTED_SPOTS: usize = MAX_SPOT_INKS * 4; + +/// Output intents looked at per page: each can cost a profile's decoding and parsing. +const MAX_OUTPUT_INTENTS: usize = 16; + +/// Decoding limit for an output intent's profile. +const MAX_PROFILE_BYTES: usize = 16 << 20; + +/// The longest output condition kept, in characters. +const MAX_CONDITION: usize = 160; + +/// The printing condition process color converts to when the page has no usable output +/// intent. +#[derive(Clone, Debug, Default)] +pub enum Fallback { + /// craft-cms's Coated FOGRA39 profile. + #[default] + Fogra39, + /// The caller's CMYK ICC profile. + Profile(Arc<[u8]>), + /// The ISO 32000-2 §10.4.2.4 device formulas, black generation and undercolor removal the + /// identity: neutral RGB goes to black alone, ICCBased CMYK separates as it is. + DeviceFormulas, +} + +/// How [`plates`] separates. +#[derive(Clone, Debug)] +pub struct Options { + /// Honor `OP`, `op` and `OPM` as a separating device does. Without it every paint knocks + /// out what is under it. + pub overprint: bool, + /// The printing condition when the page has no output intent with a usable CMYK profile. + pub fallback: Fallback, +} + +impl Default for Options { + fn default() -> Self { + Self { overprint: true, fallback: Fallback::default() } + } +} + +/// How process color that isn't CMYK already became CMYK. +#[derive(Clone, Debug, PartialEq)] +pub enum Conversion { + /// Through the CMYK profile of the page's or document's output intent (ISO 32000-2 + /// §14.11.5). + OutputIntent { + /// The profile's description, else the intent's output condition. + profile: String, + /// The intent's `/OutputConditionIdentifier`, such as `FOGRA39`. + condition: Option, + }, + /// Through [`Fallback::Fogra39`] or [`Fallback::Profile`]; the profile's description. + Fallback { profile: String }, + /// By [`Fallback::DeviceFormulas`]. + DeviceFormulas, +} + +/// A page separated into plates. +#[derive(Clone, Debug)] +pub struct Plates { + /// The process inks, then the spot inks the page paints, in the order first painted. Spots + /// carry a Lab alternate measured from their sRGB appearance at 100%. + pub inks: InkSet, + /// Spot inks past [`MAX_SPOT_INKS`]: listed, not separated. + pub unseparated: Vec, + pub width: u32, + pub height: u32, + /// The resolution the plates rendered at, after [`MAX_PLATE_PIXELS`]. + pub dpi: f32, + pub conversion: Conversion, + /// One plane per ink, in [`InkSet::iter`] order: the tint per pixel, row-major, 0 for no + /// ink and 255 for 100%. + pub planes: Vec>, +} + +impl Plates { + /// Ink `id`'s plane. + pub fn plane(&self, id: InkId) -> Option<&[u8]> { + self.planes.get(id.index()).map(Vec::as_slice) + } + + /// Ink `id`'s tint at pixel (`x`, `y`), 0–255. + pub fn tint(&self, id: InkId, x: u32, y: u32) -> Option { + if x >= self.width || y >= self.height { + return None; + } + self.plane(id)?.get(y as usize * self.width as usize + x as usize).copied() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Open(#[from] OpenError), + #[error("page {0} does not exist")] + NoPage(usize), + #[error("the resolution must be positive and finite")] + InvalidDpi, + #[error("the fallback profile can't be a printing condition: {0}")] + Profile(#[from] ProfileError), + /// After a [`raster::Error::BackendPanic`] the document is dropped; nothing else is kept. + #[error(transparent)] + Render(#[from] raster::Error), +} + +/// Separates page `page` (from 0) of `pdf` at `dpi`, reduced to fit [`MAX_PLATE_PIXELS`] and +/// the renderer's side limit: one plate per ink, the four process inks and up to +/// [`MAX_SPOT_INKS`] spot inks the page paints. Spot inks only soft masks use are left out +/// (masks don't print). Never panics. +pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result { + if !dpi.is_finite() || dpi <= 0.0 { + return Err(Error::InvalidDpi); + } + let doc = open_slice(pdf, None)?; + let (simulation, conversion) = match catch_unwind(AssertUnwindSafe(|| output_intent(&doc, page))) { + Ok(Some(found)) => (Some(found.0), found.1), + Ok(None) | Err(_) => fallback(&opts.fallback)?, + }; + let pages = doc.pages(); + let p = pages.get(page).ok_or(Error::NoPage(page))?; + let (w, h) = p.render_dimensions(); + let scale = plate_scale(w, h, dpi / 72.0); + let cache = RenderCache::new(); + + let log = Arc::new(Mutex::new(SpotLog::default())); + let discover = InterpreterSettings { ink_sink: Some(spot_sink(&log)), ..Default::default() }; + render(p, &cache, &discover, DISCOVERY_SCALE.min(scale))?; + let spots = std::mem::take(&mut log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).order); + + let mut inks = InkSet::new(); + let mut separated: Vec = PdfInk::PROCESS.to_vec(); + let mut unseparated = Vec::new(); + for (ink, swatch) in spots { + if separated.len() >= 4 + MAX_SPOT_INKS { + unseparated.push(ink.name().to_string()); + continue; + } + let alternate = Alternate::Lab(srgb8_to_lab(swatch).unwrap_or([0.0; 3])); + let spot = Ink::Spot(Spot { name: ink.name().to_string(), alternate, solidity: None }); + // A name that normalizes to one already listed (`A#2341` and `AA`) has no plate of its + // own. + match inks.add(spot) { + Some(id) if id.index() == separated.len() => separated.push(ink), + _ => unseparated.push(ink.name().to_string()), + } + } + + let mut size = None; + let mut planes = Vec::with_capacity(separated.len()); + for ink in separated { + let plate = Plate { ink, overprint: opts.overprint, simulation: simulation.clone() }; + let settings = InterpreterSettings { plate: Some(plate), ..Default::default() }; + let film = render(p, &cache, &settings, scale)?; + size = Some((film.width, film.height)); + // The film is gray on white: its red channel is the paper left uncovered. + planes.push(film.bytes().as_chunks::<4>().0.iter().map(|[red, ..]| 255 - red).collect()); + } + let (width, height) = size.unwrap_or((0, 0)); + Ok(Plates { inks, unseparated, width, height, dpi: scale * 72.0, conversion, planes }) +} + +fn render<'a>( + page: &'a craft_pdf::syntax::page::Page<'a>, + cache: &RenderCache<'a>, + settings: &InterpreterSettings, + scale: f32, +) -> Result { + // The plate's gray film must keep its tints: no color replacement. + let options = raster::Options { scale: [scale, scale], background: [255; 4], color_replacement: None, ..Default::default() }; + Ok(raster::render(page, cache, settings, &options)?) +} + +/// The scale a `w` × `h` pt page separates at: `scale`, within [`MAX_PLATE_PIXELS`] and +/// [`MAX_SIDE`]. +fn plate_scale(w: f32, h: f32, scale: f32) -> f32 { + let (w, h) = (w.max(1.0), h.max(1.0)); + let by_area = (MAX_PLATE_PIXELS / (w * h)).sqrt(); + let by_side = MAX_SIDE as f32 / w.max(h); + let s = scale.min(by_area).min(by_side); + if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE } +} + +fn fallback(fallback: &Fallback) -> Result<(Option, Conversion), Error> { + let (conversion, icc): (CmsConversion, &[u8]) = match fallback { + Fallback::DeviceFormulas => return Ok((None, Conversion::DeviceFormulas)), + Fallback::Fogra39 => (CmsConversion::fogra39(), craft_cms::builtin::COATED_FOGRA39_ICC), + Fallback::Profile(icc) => (CmsConversion::new(icc)?, icc), + }; + let simulation = conversion.simulation(icc); + let profile = simulation.description().unwrap_or("unnamed CMYK profile").to_string(); + Ok((Some(simulation), Conversion::Fallback { profile })) +} + +/// Spot inks reported while a page renders, in the order first painted, each with its sRGB +/// appearance at 100%. +#[derive(Default)] +struct SpotLog { + order: Vec<(PdfInk, [u8; 3])>, + seen: HashSet, +} + +fn spot_sink(log: &Arc>) -> InkSinkFn { + let log = log.clone(); + Arc::new(move |ink: &PdfInk, appearance: &dyn Fn(f32) -> [u8; 3]| { + let mut log = log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if log.seen.contains(ink) || log.order.len() >= MAX_LISTED_SPOTS { + return; + } + log.seen.insert(ink.clone()); + log.order.push((ink.clone(), appearance(1.0))); + }) +} + +/// A text string entry of `dict`, without control characters and at most [`MAX_CONDITION`] +/// characters long; `None` when missing or blank. +fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option { + let s = dict.get::>(key)?; + let text: String = pdf_text(s.as_bytes()).chars().filter(|c| !c.is_control()).take(MAX_CONDITION).collect(); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +/// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else +/// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation). +fn pdf_text(bytes: &[u8]) -> String { + if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) { + let units: Vec = utf16.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + return String::from_utf16_lossy(&units); + } + if let Some(utf8) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(utf8).into_owned(); + } + bytes.iter().map(|b| char::from(*b)).collect() +} + +/// The printing condition of page `page` (ISO 32000-2 §14.11.5): of the page's own +/// `/OutputIntents` (PDF 2.0), else the catalog's, the first whose `/DestOutputProfile` is a +/// CMYK profile craft-cms converts into, a `GTS_PDFX` intent before the others. +fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> { + fn list<'a>(intents: Option>) -> Vec> { + intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect() + } + let xref = pdf.xref(); + let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS))); + if listed.is_empty() { + listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS))); + } + // A stable sort: the PDF/X intent first, the others in their order. + listed.sort_by_key(|d| d.get::>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX")); + listed.iter().find_map(|intent| { + let icc = intent.get::>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?; + let simulation = CmsConversion::new(&icc).ok()?.simulation(&icc); + let condition = text_entry(intent, OUTPUT_CONDITION_IDENTIFIER); + let profile = simulation + .description() + .map(str::to_string) + .or_else(|| text_entry(intent, OUTPUT_CONDITION)) + .or_else(|| condition.clone()) + .unwrap_or_else(|| "unnamed CMYK profile".to_string()); + Some((simulation, Conversion::OutputIntent { profile, condition })) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plates_fit_the_pixel_and_side_caps() { + assert_eq!(plate_scale(40.0, 40.0, 1.0), 1.0); + // A4 at 2400 dpi would be 22 000 × 31 000 pixels. + let s = plate_scale(595.0, 842.0, 2400.0 / 72.0); + assert!((595.0 * s) * (842.0 * s) <= MAX_PLATE_PIXELS * 1.0001); + // A 1 × 10 000 pt strip fits the area cap but not the renderer's side limit. + assert!(10_000.0 * plate_scale(1.0, 10_000.0, 1.0) <= MAX_SIDE as f32); + assert!(plate_scale(0.0, f32::NAN, 1.0) > 0.0); + } +} diff --git a/crates/prepress/tests/plates.rs b/crates/prepress/tests/plates.rs new file mode 100644 index 00000000..2b27dadf --- /dev/null +++ b/crates/prepress/tests/plates.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; + +use craft_cms::{Intent, Profile, Transform, builtin}; +use craft_prepress::{Alternate, CmsConversion, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; + +/// A 40 × 40 pt page with `content` and `resources`, `catalog` entries, and `objects` +/// numbered from 5 (each a whole object body). +fn page_pdf(content: &str, resources: &str, catalog: &str, objects: &[Vec]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + out.extend_from_slice(format!("1 0 obj << /Type /Catalog /Pages 2 0 R {catalog} >> endobj\n").as_bytes()); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + out.extend_from_slice( + format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(), + ); + out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + for (i, body) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b" endobj\n"); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +/// A spot color whose alternate is a yellowish CMYK. +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]"; + +/// Black over the page, Gold at 50% in the middle square, overprinting when `gs` sets `op`. +fn black_and_gold(gs: &str) -> Vec { + let resources = format!("/ColorSpace << /CS0 {GOLD} >> /ExtGState << /OP << /op true >> >>"); + page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} /CS0 cs 0.5 scn 10 10 20 20 re f"), &resources, "", &[]) +} + +fn black() -> InkId { + InkId::process(Process::Black) +} + +#[test] +fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() { + let p = plates(&black_and_gold(""), 0, 72.0, &Options::default()).unwrap(); + assert_eq!((p.width, p.height, p.dpi), (40, 40, 72.0)); + let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]); + assert_eq!(p.planes.len(), 5); + assert!(p.unseparated.is_empty()); + let gold = p.inks.lookup("Gold").unwrap(); + let Some(Ink::Spot(spot)) = p.inks.get(gold) else { panic!("Gold is a spot ink") }; + let Alternate::Lab([l, _, b]) = spot.alternate else { panic!("a Lab alternate") }; + assert!(l > 60.0 && b > 40.0, "Gold looks yellow: {:?}", spot.alternate); + + let half = |t: Option| t.is_some_and(|t| t.abs_diff(128) <= 1); + assert!(half(p.tint(gold, 20, 20)), "Gold at 50%"); + assert_eq!(p.tint(black(), 20, 20), Some(0), "Gold knocks out black"); + assert_eq!(p.tint(black(), 2, 2), Some(255)); + assert_eq!(p.tint(gold, 2, 2), Some(0)); + for c in [Process::Cyan, Process::Magenta, Process::Yellow] { + assert!(p.plane(InkId::process(c)).unwrap().iter().all(|t| *t == 0), "{c:?} is empty"); + } + + let over = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options::default()).unwrap(); + assert!(half(over.tint(gold, 20, 20))); + assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted"); + let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap(); + assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out"); + + // The preview reads the plates: knocked-out Gold shows pale on paper, overprinted Gold + // darkens the black under it. + let cms = CmsConversion::fogra39(); + let px = |rgb: &[u8], x: usize, y: usize| -> [u8; 3] { rgb[(y * 40 + x) * 3..][..3].try_into().unwrap() }; + let (knockout, overprint) = (p.composite(&[], &cms), over.composite(&[], &cms)); + let [r, g, b] = px(&knockout, 20, 20); + assert!(r > 200 && g > 180 && b < g, "50% Gold on paper: {:?}", [r, g, b]); + let k = px(&knockout, 2, 2); + assert!(k.iter().all(|c| *c < 80), "black: {k:?}"); + assert_eq!(px(&overprint, 2, 2), k); + let dark = px(&overprint, 20, 20); + assert!(dark.iter().zip(k).all(|(d, k)| *d <= k) && dark[2] < k[2], "Gold over black: {dark:?}, black {k:?}"); +} + +/// What `profile` prints for sRGB red, as tint bytes. +fn red_in(profile: &[u8]) -> [u8; 4] { + let t = Transform::new(builtin::srgb(), &Profile::parse(profile).unwrap(), Intent::RelativeColorimetric, false).unwrap(); + let mut cmyk = [0.0_f32; 4]; + t.convert_f32(&[1.0, 0.0, 0.0], 3, &mut cmyk, 4, false).unwrap(); + cmyk.map(|v| (v * 255.0).round() as u8) +} + +fn red_tints(p: &craft_prepress::Plates) -> [u8; 4] { + Process::ALL.map(|c| p.tint(InkId::process(c), 20, 20).unwrap()) +} + +fn near(got: [u8; 4], expected: [u8; 4]) -> bool { + got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= 3) +} + +#[test] +fn process_color_converts_through_the_output_intent_else_the_fallback() { + let synthetic = builtin::COATED_CMYK_ICC; + let intent = "/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Synthetic press) /DestOutputProfile 5 0 R >>]"; + let red = |catalog: &str| page_pdf("1 0 0 rg 0 0 40 40 re f", "", catalog, &[stream("/N 4", synthetic)]); + + let p = plates(&red(intent), 0, 72.0, &Options::default()).unwrap(); + let profile = craft_cms::synth::DESCRIPTION.to_string(); + assert_eq!(p.conversion, Conversion::OutputIntent { profile: profile.clone(), condition: Some("Synthetic press".into()) }); + let expected = red_in(synthetic); + assert!(near(red_tints(&p), expected), "{:?}, the output intent prints {expected:?}", red_tints(&p)); + + let fogra = plates(&red(""), 0, 72.0, &Options::default()).unwrap(); + assert_eq!(fogra.conversion, Conversion::Fallback { profile: builtin::COATED_FOGRA39_DESCRIPTION.into() }); + let expected = red_in(builtin::COATED_FOGRA39_ICC); + assert!(near(red_tints(&fogra), expected), "{:?}, FOGRA39 prints {expected:?}", red_tints(&fogra)); + + let callers = Options { fallback: Fallback::Profile(Arc::from(synthetic)), ..Options::default() }; + assert_eq!(plates(&red(""), 0, 72.0, &callers).unwrap().conversion, Conversion::Fallback { profile }); + + let formulas = Options { fallback: Fallback::DeviceFormulas, ..Options::default() }; + let device = plates(&red(""), 0, 72.0, &formulas).unwrap(); + assert_eq!((device.conversion.clone(), red_tints(&device)), (Conversion::DeviceFormulas, [0, 255, 255, 0])); + + let broken = Options { fallback: Fallback::Profile(Arc::from(&b"not a profile"[..])), ..Options::default() }; + assert!(matches!(plates(&red(""), 0, 72.0, &broken), Err(Error::Profile(_)))); +} + +#[test] +fn spot_inks_past_the_cap_are_listed_not_separated_and_bad_input_is_refused() { + let n = MAX_SPOT_INKS + 2; + let spaces: String = + (0..n).map(|i| format!("/S{i} [/Separation /Spot{i} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] ")).collect(); + let content: String = (0..n).map(|i| format!("/S{i} cs 1 scn {i} 0 1 1 re f ")).collect(); + let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]); + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + assert_eq!((p.inks.len(), p.planes.len()), (4 + MAX_SPOT_INKS, 4 + MAX_SPOT_INKS)); + assert_eq!(p.unseparated, [format!("Spot{}", n - 2), format!("Spot{}", n - 1)]); + + assert!(matches!(plates(&pdf, 1, 72.0, &Options::default()), Err(Error::NoPage(1)))); + assert!(matches!(plates(&pdf, 0, f32::NAN, &Options::default()), Err(Error::InvalidDpi))); + assert!(matches!(plates(b"not a pdf", 0, 72.0, &Options::default()), Err(Error::Open(_)))); +} + +#[test] +fn lab_colors_convert_through_the_profile_and_lab_white_is_paper() { + let resources = "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] >>"; + let pdf = page_pdf("/D50 cs 50 20 -10 scn 0 0 20 40 re f /D65 cs 100 0 0 scn 20 0 20 40 re f", resources, "", &[]); + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + let at = |x| Process::ALL.map(|c| p.tint(InkId::process(c), x, 20).unwrap()); + let fogra = Profile::parse(builtin::COATED_FOGRA39_ICC).unwrap(); + let t = Transform::new(craft_cms::Builtin::LabD50.profile(), &fogra, Intent::RelativeColorimetric, false).unwrap(); + let mut cmyk = [0.0_f32; 4]; + t.convert_f32(&[0.5, 148.0 / 255.0, 118.0 / 255.0], 3, &mut cmyk, 4, false).unwrap(); + let expected = cmyk.map(|v| (v * 255.0).round() as u8); + assert!(near(at(10), expected), "{:?}, FOGRA39 prints {expected:?}", at(10)); + assert_eq!(at(30), [0, 0, 0, 0], "a Lab space's own white prints as paper"); +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 704ce25d..07048f41 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -1,11 +1,61 @@ # Shared Hayro reader snapshot The `hayro`, `hayro-interpret`, `hayro-syntax` and `hayro-jbig2` directories are transferred -from [PdfCraft revision 7437bde378f43856eada40af25b983812e615e8a](https://github.com/storytold/pdfcraft/tree/7437bde378f43856eada40af25b983812e615e8a/vendor). -The upstream [patch ledger](https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/README.md) +from [PdfCraft revision ec0a965074f376a5bf610435948e4e5184bf3299](https://github.com/storytold/pdfcraft/tree/ec0a965074f376a5bf610435948e4e5184bf3299/vendor) +(`main`, 2026-10-19). The upstream [patch ledger](https://github.com/storytold/pdfcraft/blob/ec0a965074f376a5bf610435948e4e5184bf3299/vendor/README.md) describes the inherited patches. Source manifests record every transferred Git blob and SHA-256 alongside the local hash. The source manifests themselves are not self-hashed. +The first transfer was from PdfCraft revision 7437bde378f43856eada40af25b983812e615e8a. +The snapshot was then re-synced to ec0a965 by a three-way merge (base 7437bde, PdfCraft's +current tree, this tree), so every local patch described below is carried unchanged. The +re-sync brings these PdfCraft changes: + +| PdfCraft PR | Crate | Change | +|---|---|---| +| #1691 | hayro-interpret | Patch 28: images with few possible pixel values convert each value once (spot-color images) | +| #1903, #3316 | hayro-syntax | CCITT images that declare `/Rows 0`, or fewer `/Rows` than `/Height`, decode to their height | +| #1954 | hayro-syntax | ASCII85 decoding stops at the stream's decode limit (here the per-document limit) | +| #1982 | hayro-syntax | `Page::base_dimensions` keeps a one-point floor after `/UserUnit` | +| #2027 | hayro-interpret | Reads a transparency group's `/I` and `/K`; a group that is neither isolated nor knockout composites directly onto its backdrop | +| #2132 | hayro | A soft mask fades a gradient fill | +| #1557 | hayro-interpret, hayro | Patch 29: devices are told where annotation drawing begins and ends | +| 13e3154 | hayro-interpret | Annex D.2 alias codes in standard, TrueType, Type 1 and Type 3 fonts read as the glyphs they draw | +| #2436 | hayro-interpret | Patches 31–33: faster sampled functions, image decodes reused across zoom levels, patch meshes interpolated once per grid point | +| #2812 | hayro-interpret | Patch 34: a per-thread tint-transform counter for deterministic tests | +| #2035 | hayro-syntax | Flate back-references are copied in runs | +| #3356 | hayro | A tiling pattern drawn once shows its cell at full scale | +| #3378 | hayro-syntax | Patch 20: spaces or tabs between `stream` and its line break are accepted | +| #3363 | hayro-syntax | A JP2 image in a color space the decoder refuses is drawn by its component count | +| #3412 | hayro-interpret | Type 3 glyph bounding boxes are transformed to device space before shading bounds are intersected | + +PdfCraft's vendored `vello_common` patch (clip-intersection performance) is not part of this +reader snapshot. + +Changes ported from PdfCraft pull requests that were open when they were ported, under the +same MIT OR Apache-2.0 license. Code comments keep the pull request's patch numbers, except +where noted below. + +| PdfCraft PR | Author | Source commit | Files | Change | +|---|---|---|---|---| +| [#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 color to an output intent's CMYK. Only the vendored interpreter part is ported; PdfCraft's `separations.rs` and Output Preview UI are not | +| [#2974](https://github.com/storytold/pdfcraft/pull/2974) | mtlcl410 | [bae6981](https://github.com/mtlcl410/pdfcraft/commit/bae69819594a6385a027fec1f67e6b3c36c259b4) | hayro-interpret `device.rs`, `x_object.rs`; hayro `renderer.rs` | Knockout groups (`/K true`) knock out: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage); an unmasked image's rectangle (`Device::knockout_image`) and a nested group's shape (an erase pass between `Device::begin_knockout_erase` and `end_knockout_erase`) are cleared before their layer composites; a transparency group drawn in a knockout group always gets a layer of its own. Masked images and stencils composite as before | + +Code comments label #2974's changes `Knockout patch (pdfcraft #2974)` instead of a number: the +pull request numbers them hayro-interpret patch 39 and hayro patch 19, and PdfCraft may +renumber them when it merges. `crates/pdf/tests/knockout.rs` adapts its test; a plate-mode +test in `crates/pdf/tests/plates.rs` checks that a knockout group separates with only the top +object where two objects overlap. The erase pass resolves the group's resources with +`Resources::from_scope`, as the group's own draw does in this tree. + +The output-intent conversion of patch 36 goes through a `color::CmykConversion` trait, so +that callers can convert with their own color management. `CmykSimulation::new` keeps +#2770's moxcms conversion as the default, and `CmykSimulation::with_conversion` takes a +caller's. Lab and ICCBased colors 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 27811044..bacab802 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro-interpret", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro-interpret", "files": [ { @@ -174,21 +174,21 @@ }, { "path": "src/cache.rs", - "source_sha256": "a0a46ddbab7ed00b6bd0fb09f10d545c06bb4374ea0f520ae118c8d4f7b3c55d", - "source_git_blob": "2dda2e8a7cc0a2491b253ba1f42adac6074b1b34", - "sha256": "a0a46ddbab7ed00b6bd0fb09f10d545c06bb4374ea0f520ae118c8d4f7b3c55d" + "source_sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42", + "source_git_blob": "e253a7b8c7dbfbfa2f7dc1f6c582559962060b94", + "sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42" }, { "path": "src/color.rs", - "source_sha256": "c78cad4124e7bb203e0763f6bea36c542280fd0a8abfa3f7b3ea0bd728adb73b", - "source_git_blob": "dd2edf241d60f146ddb8ea4cbd4ad12a127fa0a7", - "sha256": "bc9b67008e8f7e5ba7bd96a9b49b6ec5d2818eedc44e003212342d8bc259cc36" + "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", + "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", + "sha256": "50a039fbf0d8122b31fe03c0a06e2d4bb2994f50fd04072f6637233bc2ff9eb6" }, { "path": "src/context.rs", - "source_sha256": "e6898005282196c77bf44357237c79f8c839e74f666e8157756230fd9e2f93ed", - "source_git_blob": "3e83229c5f58cfbbefd15bf3b5e7b71380291c01", - "sha256": "86e838ca19dfc47f5bb0d7a2ffff80709f2fd51bc6b62d65151841bcba212e08" + "source_sha256": "dc2be553958ee3f5e5e644a77a0301723bae9ca027a352c13b9caaf549cc0e56", + "source_git_blob": "d6772baa15ebb95d3ba2d1f54ce5c250ad1e8137", + "sha256": "d2dc5a6e8e4c066667a92892470315e7447b216048125d67b7674d25a6f3a265" }, { "path": "src/convert.rs", @@ -198,15 +198,15 @@ }, { "path": "src/device.rs", - "source_sha256": "b95b9c91184dde6bb7dcf2dfcad1244f44ae5c73b02d78ea6cd2c692ac8a5ce4", - "source_git_blob": "72d5a98098cb3544259dcb05df7b5b265310ff2f", - "sha256": "b95b9c91184dde6bb7dcf2dfcad1244f44ae5c73b02d78ea6cd2c692ac8a5ce4" + "source_sha256": "6f0c1203d1e60724cdd11e9a230249247a70f578c4a7ccb9bf39a23411b32db6", + "source_git_blob": "36c64a23fd23f6c4abad941077cf93296a67841f", + "sha256": "abe1a0a835deb409b4d466e1d0f041a0e53ef8981be0929070afc77258cd8434" }, { "path": "src/encode.rs", - "source_sha256": "68973a358f38bd6d7f09277e6319506122f586d1cda092b933c2a18680fe6d8f", - "source_git_blob": "498ed909ca65f9765b4f0c3b2ea10386e2e6c48d", - "sha256": "68973a358f38bd6d7f09277e6319506122f586d1cda092b933c2a18680fe6d8f" + "source_sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6", + "source_git_blob": "b3f830ba2dcdb7069ea73706be43c55be398f670", + "sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6" }, { "path": "src/font/blob.rs", @@ -288,9 +288,9 @@ }, { "path": "src/font/mod.rs", - "source_sha256": "f7a8cfe30893115cc05906cbec747ad368b5645de101c8cbf7bf622f7611973b", - "source_git_blob": "5ccf5c48a6132b7704baff81ece3492a6a7acae2", - "sha256": "f7a8cfe30893115cc05906cbec747ad368b5645de101c8cbf7bf622f7611973b" + "source_sha256": "7e6411927d91205ca76c6b59f9d737cd1104d066f8cfcc0a87f481016ef30313", + "source_git_blob": "7c8c8d086d59139df791cdb6952506473ec4b5a7", + "sha256": "7e6411927d91205ca76c6b59f9d737cd1104d066f8cfcc0a87f481016ef30313" }, { "path": "src/font/outline.rs", @@ -300,27 +300,27 @@ }, { "path": "src/font/standard_font.rs", - "source_sha256": "048bcfb0d7d63ced49cef583c954b7507e5f078313caf5cf565adb97ad4bda46", - "source_git_blob": "c73c75dd22c7937585a0646f49bc89514ce054df", - "sha256": "048bcfb0d7d63ced49cef583c954b7507e5f078313caf5cf565adb97ad4bda46" + "source_sha256": "af3c51592c086671cb127745d4351d1380ef1212aee325099f1e933607995d7d", + "source_git_blob": "139454a0b21eb992e9e52bf73c85e965c0f86cda", + "sha256": "af3c51592c086671cb127745d4351d1380ef1212aee325099f1e933607995d7d" }, { "path": "src/font/true_type.rs", - "source_sha256": "8e8ffcf8f9749b10262793f1ecaf86ac7c65e6d5608e013d938a03adf210ad84", - "source_git_blob": "40f33590309100f295cb441069c340f9e034b49b", - "sha256": "8e8ffcf8f9749b10262793f1ecaf86ac7c65e6d5608e013d938a03adf210ad84" + "source_sha256": "278fed0cddcc43cc7d616200dcefb45720db9d560f54e41a82c281c3163b411d", + "source_git_blob": "b36266985942b4d7e2970f9edf7329c4edbcfa11", + "sha256": "278fed0cddcc43cc7d616200dcefb45720db9d560f54e41a82c281c3163b411d" }, { "path": "src/font/type1.rs", - "source_sha256": "5779b825fa6a5589978ed5b30910b7af0986ace42bac4e3652f7975a479874d4", - "source_git_blob": "32b5e3ddd9a2ca877d760bc5150bd55c3302bb33", - "sha256": "5779b825fa6a5589978ed5b30910b7af0986ace42bac4e3652f7975a479874d4" + "source_sha256": "431b1fe86ca51bd993e81084a64c3e8ed8b90a67385a30eb4ffedfdd98c6f96b", + "source_git_blob": "02f76bfb8095041e3d54df014190047de46be4c5", + "sha256": "431b1fe86ca51bd993e81084a64c3e8ed8b90a67385a30eb4ffedfdd98c6f96b" }, { "path": "src/font/type3.rs", - "source_sha256": "257f7a2c8780c8e565f46dfd7dd1e3d50bef1e2c44aae5777994addb0ca26d62", - "source_git_blob": "ccfeeb766472f16f15b04710bab711f669b94adf", - "sha256": "5bd99e9a219c4ed437f964b3b81e10381ad4f0bfdee8b2f9be1a6d3940fcd9b4" + "source_sha256": "bc33784f0dd3e599028b28b3696b9e4380cbff08f4c4e6299dee51f8df5eb08f", + "source_git_blob": "c0e0d0b2df8f38183b8e23e9e8f644c0b81d0529", + "sha256": "1778362c6c4a5cb015e756fd3f36afd7482f20a230dde0206f4339da922eaca4" }, { "path": "src/function/mod.rs", @@ -330,9 +330,9 @@ }, { "path": "src/function/type0.rs", - "source_sha256": "84cb6ccb45536ca5731394558883350f43b5e66330e07eb8d5a0e070c8e20704", - "source_git_blob": "02bbc41b84caddc9b5dfc8f49b2da081eb281199", - "sha256": "84cb6ccb45536ca5731394558883350f43b5e66330e07eb8d5a0e070c8e20704" + "source_sha256": "a10a3a8c2154c19c5c637d5ff70b87b4d28a8bb857d82f2b06de68414ef93be7", + "source_git_blob": "9a82c5978657170c44a2e2b771f2610ade5fb292", + "sha256": "a10a3a8c2154c19c5c637d5ff70b87b4d28a8bb857d82f2b06de68414ef93be7" }, { "path": "src/function/type2.rs", @@ -360,21 +360,21 @@ }, { "path": "src/interpret/mod.rs", - "source_sha256": "2b286df0c34b8194e1bdd1b63c1e08354d24f8389958fd782f42e2cabf364673", - "source_git_blob": "931b3388cef188cee0a5a1d495b69a6ff04f5e51", - "sha256": "6ae30906c8e6d0e869e0fac8ece4d97e0ac08d8abd7042c0242a069d4e609351" + "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", + "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", + "sha256": "6b0c0080f1301bcdc24c03777b056d0501815c82cf958923595b29266349f50c" }, { "path": "src/interpret/path.rs", "source_sha256": "a5defbc4b883b262662f7e7474a00ec1f97dbef8c03b8669859f624f5d02344b", "source_git_blob": "838ea099f11d918d6e333e32503368be864689f8", - "sha256": "a5defbc4b883b262662f7e7474a00ec1f97dbef8c03b8669859f624f5d02344b" + "sha256": "eb42777dc3063794c21aa914b61e40a8547820de3e4d7b47924d169890f4fbb4" }, { "path": "src/interpret/state.rs", "source_sha256": "dce778291b13f6df83c8c13aee22f60edd033ec9f7d89309ef148d46d3ba9814", "source_git_blob": "95b773fb5cc434ab8db362d00e4e5180a8b740c9", - "sha256": "dce778291b13f6df83c8c13aee22f60edd033ec9f7d89309ef148d46d3ba9814" + "sha256": "393371ec56dccdc3c2fc20d61da39d87e3e8393bf69ddbd249417490f8f9923b" }, { "path": "src/interpret/text.rs", @@ -404,19 +404,19 @@ "path": "src/pattern.rs", "source_sha256": "17b49a465275c20e6645dea9443f2cb3a64c594925fd860e74b80b44d5344e37", "source_git_blob": "9295f51709f8915951d7b9afca9411651d65a1f8", - "sha256": "24ac7a1cfd9a55b7d40d572120f52743bd390fab0007603aec64034ae7d2fd23" + "sha256": "ae362a5074b40482f2c5e16ea66a0398b27ec866a7b4adb8f45fe7221c960ae1" }, { "path": "src/shading.rs", - "source_sha256": "a28f730ede65bb4e480d0a6ae49a21e69ed23d7b0fd6f8006e487c83faa0756d", - "source_git_blob": "98fbb58310900160d1d4067e0707708ba2f673d0", - "sha256": "a28f730ede65bb4e480d0a6ae49a21e69ed23d7b0fd6f8006e487c83faa0756d" + "source_sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", + "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", + "sha256": "6e80c039ab7ca38ede2b1778709f1b4258d8cf219d044d3298f4a5f6dd2c8f9a" }, { "path": "src/soft_mask.rs", "source_sha256": "6cd9c59c0cff580879c1f06ff40f3537c04b719bdb3ea59008fa1ab7559db5dd", "source_git_blob": "4d97d565b53c820fa927fbba8c3dda6acead7f34", - "sha256": "780ffbf73567a7ae33f2fb673676840e0a29cb6b59f1d8b625034f42c4d17a22" + "sha256": "0e3871896575006f194fb46242fcaa6e1026f459c6144c38da438f82ace95e50" }, { "path": "src/types.rs", @@ -432,9 +432,9 @@ }, { "path": "src/x_object.rs", - "source_sha256": "b325843a8f4088cdb1cc1d471971bd88932fb1e538d06a20a1cc2bee138d5624", - "source_git_blob": "68d97d55554888b9afe6c16d08ab2cbaca61924e", - "sha256": "93539c173b193aa740aa7a839ec05c9676a89cbee404726767cf82b63f19bda6" + "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", + "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", + "sha256": "a1828456a715f0d2a6a76ab10656a018d4b94c4012050b739bce09728ac27225" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index 2dda2e8a..e253a7b8 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -72,7 +72,8 @@ pub(crate) struct DecodedImageCache { /// - the dictionary's bytes (`dict_hash`: `/Decode`, `/ColorSpace`, `/SMask`, `/Mask`, /// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it); /// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation, -/// the decode target size, and preview or final quality. +/// the decode target size (only for an image a JPX stream decodes, which is the only +/// decode that reads it: PdfCraft patch 32), and preview or final quality. /// /// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so /// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in @@ -95,6 +96,8 @@ pub(crate) struct DecodedImageKey { struct DecodedImageCacheInner { map: HashMap>>, bytes: usize, + /// PdfCraft patch (32): how many keyed decodes ran (cache misses). + decodes: usize, } impl DecodedImageCache { @@ -105,6 +108,12 @@ impl DecodedImageCache { } } + /// PdfCraft patch (32): how many decodes the cache was asked to keep (its misses), for + /// diagnostics and tests. 0 when the lock is poisoned. + pub(crate) fn decodes(&self) -> usize { + self.inner.lock().map(|locked| locked.decodes).unwrap_or(0) + } + /// The same cache, for a preview (`true`) or a final render. pub(crate) fn for_render(&self, preview: bool) -> Self { Self { @@ -141,6 +150,7 @@ impl DecodedImageCache { let Ok(mut locked) = self.inner.lock() else { return val; }; + locked.decodes = locked.decodes.saturating_add(1); // Another thread may have decoded it meanwhile: keep the first copy. if let Some(existing) = locked.map.get(&key) && let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::()) diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 4db84631..981ec019 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -2,6 +2,7 @@ use crate::cache::{Cache, CacheKey}; use crate::function::Function; +use crate::util::hash128; use hayro_syntax::object; use hayro_syntax::object::Array; use hayro_syntax::object::Dict; @@ -10,8 +11,8 @@ use hayro_syntax::object::Object; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::*; use moxcms::{ - ColorProfile, DataColorSpace, Layout, Transform8BitExecutor, TransformF32Executor, - TransformOptions, + ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, Transform8BitExecutor, + TransformF32Executor, TransformOptions, }; use smallvec::{SmallVec, ToSmallVec, smallvec}; use std::borrow::Cow; @@ -22,6 +23,25 @@ use std::sync::{Arc, LazyLock, OnceLock}; /// A storage for the components of colors. pub type ColorComponents = SmallVec<[f32; 4]>; +std::thread_local! { + // PdfCraft patch (34): see `tint_transform_runs`. + static TINT_TRANSFORM_RUNS: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN +/// tint transform, for diagnostics and tests. An image converted through patch (28)'s lookup +/// table runs it once per possible sample value, not once per pixel. Per thread, so a caller +/// counting its own render is not disturbed by renders on other threads. +pub fn tint_transform_runs() -> u64 { + TINT_TRANSFORM_RUNS.with(core::cell::Cell::get) +} + +/// PdfCraft patch (34): count `colours` runs of a tint transform (called once per batch). +fn count_tint_transform_runs(colours: usize) { + let colours = u64::try_from(colours).unwrap_or(u64::MAX); + TINT_TRANSFORM_RUNS.with(|runs| runs.set(runs.get().saturating_add(colours))); +} + /// An RGB color with an alpha channel. #[derive(Debug, Copy, Clone)] pub struct AlphaColor { @@ -98,6 +118,8 @@ pub(crate) enum ColorSpaceType { Lab(Lab), Separation(Separation), DeviceN(DeviceN), + /// PdfCraft patch (35): `inner` drawn as the plate of one ink (see [`Plate`]). + Plate(PlateSpace), } impl ColorSpaceType { @@ -251,7 +273,12 @@ impl ColorSpace { /// Return `true` if the current color space is an indexed color space. pub(crate) fn is_indexed(&self) -> bool { - matches!(self.0.as_ref(), ColorSpaceType::Indexed(_)) + match self.0.as_ref() { + ColorSpaceType::Indexed(_) => true, + // PdfCraft patch (35): a plate decodes its samples as its space does. + ColorSpaceType::Plate(p) => p.inner.is_indexed(), + _ => false, + } } /// Get the default decode array for the color space. @@ -271,6 +298,7 @@ impl ColorSpace { ColorSpaceType::Indexed(_) => smallvec![(0.0, 2.0_f32.powf(n) - 1.0)], ColorSpaceType::Separation(_) => smallvec![(0.0, 1.0)], ColorSpaceType::DeviceN(d) => smallvec![(0.0, 1.0); d.num_components as usize], + ColorSpaceType::Plate(p) => p.inner.default_decode_arr(n), // Not a valid image color space. ColorSpaceType::Pattern(_) => smallvec![(0.0, 1.0)], } @@ -302,6 +330,7 @@ impl ColorSpace { ColorSpaceType::Separation(_) => smallvec![1.0], ColorSpaceType::Pattern(c) => c.initial_color(), ColorSpaceType::DeviceN(d) => smallvec![1.0; d.num_components as usize], + ColorSpaceType::Plate(p) => p.inner.initial_color(), } } @@ -323,6 +352,7 @@ impl ColorSpace { ColorSpaceType::Separation(_) => 1, ColorSpaceType::Pattern(p) => p.num_components(), ColorSpaceType::DeviceN(d) => d.num_components, + ColorSpaceType::Plate(p) => p.inner.num_components(), } } @@ -373,6 +403,7 @@ impl ToRgb for ColorSpace { ColorSpaceType::Lab(i) => i.convert_f32(input, output, manual_scale), ColorSpaceType::Separation(i) => i.convert_f32(input, output, manual_scale), ColorSpaceType::DeviceN(i) => i.convert_f32(input, output, manual_scale), + ColorSpaceType::Plate(i) => i.convert_f32(input, output, manual_scale), } } @@ -389,6 +420,7 @@ impl ToRgb for ColorSpace { ColorSpaceType::Lab(i) => i.supports_u8(), ColorSpaceType::Separation(i) => i.supports_u8(), ColorSpaceType::DeviceN(i) => i.supports_u8(), + ColorSpaceType::Plate(i) => i.supports_u8(), } } @@ -417,6 +449,7 @@ impl ToRgb for ColorSpace { ColorSpaceType::Lab(i) => i.convert_u8(input, output), ColorSpaceType::Separation(i) => i.convert_u8(input, output), ColorSpaceType::DeviceN(i) => i.convert_u8(input, output), + ColorSpaceType::Plate(i) => i.convert_u8(input, output), } } @@ -424,6 +457,7 @@ impl ToRgb for ColorSpace { match self.0.as_ref() { ColorSpaceType::Separation(s) => s.is_none(), ColorSpaceType::DeviceN(d) => d.is_none(), + ColorSpaceType::Plate(p) => p.inner.is_none(), _ => false, } } @@ -682,6 +716,11 @@ impl Lab { /// be expressed in LMS before taking their ratios. CalRGB's separate /// flat-white/black-point pipeline retains its existing normalization. fn adapt_to_d65(white_point: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { + Self::adapt(white_point, &CalRgb::D65_WHITEPOINT, xyz) + } + + /// `xyz`, seen under `white_point`, Bradford-adapted to `destination`. + fn adapt(white_point: &[f32; 3], destination: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { let source_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, white_point); // A malformed but positive XYZ white can still yield a zero or // overflowing cone response. Leave it unadapted rather than divide by @@ -689,7 +728,7 @@ impl Lab { if source_lms.iter().any(|v| !v.is_finite() || v.abs() <= f32::EPSILON) { return *xyz; } - let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, &CalRgb::D65_WHITEPOINT); + let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, destination); let lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, xyz); let adapted = [ lms[0] * destination_lms[0] / source_lms[0], @@ -709,43 +748,60 @@ impl Lab { 3.0 * DELTA * DELTA * (t - 4.0 / 29.0) } } + + /// PdfCraft patch: the L*, a* and b* of the colour `input`, clamped to L* 0..100 and the + /// a*/b* /Range. `manual_scale` means the components arrive normalised to [0, 1] (an + /// `/Indexed` palette over Lab looks its bytes up as `byte / 255`): they map linearly onto + /// L* 0..100 and the /Range (ISO 32000-2 §8.6.6.3). + fn components(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { + let [min_a, max_a, min_b, max_b] = self.range; + let get = |k: usize| input.get(k).copied().unwrap_or(0.0); + let (l, a, b) = if manual_scale { + ( + clamp_component(get(0), 0.0, 1.0) * 100.0, + min_a + clamp_component(get(1), 0.0, 1.0) * (max_a - min_a), + min_b + clamp_component(get(2), 0.0, 1.0) * (max_b - min_b), + ) + } else { + (get(0), get(1), get(2)) + }; + [ + clamp_component(l, 0.0, 100.0), + clamp_component(a, min_a, max_a), + clamp_component(b, min_b, max_b), + ] + } + + /// The XYZ of L*a*b*, in the coordinates of the space's white point. + fn xyz(&self, [l, a, b]: [f32; 3]) -> [f32; 3] { + let [xw, yw, zw] = self.usable_white_point(); + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + [ + xw * Self::inverse_lab_f(fx), + yw * Self::inverse_lab_f(fy), + zw * Self::inverse_lab_f(fz), + ] + } + + /// PdfCraft patch (36): the color `input` as XYZ relative to [`PCS_WHITE`], relative + /// colorimetric: the space's white point becomes the PCS white (Bradford-adapted). + fn to_pcs(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { + let xyz = self.xyz(self.components(input, manual_scale)); + Self::adapt(&self.usable_white_point(), &PCS_WHITE, &xyz) + } } impl ToRgb for Lab { fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> { - let [min_a, max_a, min_b, max_b] = self.range; let white_point = self.usable_white_point(); - let [xw, yw, zw] = white_point; for (input, output) in input.chunks_exact(3).zip(output.chunks_exact_mut(3)) { - // PdfCraft patch: `manual_scale` means the components arrive - // normalised to [0, 1] (an `/Indexed` palette over Lab looks its - // bytes up as `byte / 255`). Map them linearly onto L* 0..100 and - // the a*/b* /Range (ISO 32000-2 §8.6.6.3) before converting. - let (l, a, b) = if manual_scale { - ( - clamp_component(input[0], 0.0, 1.0) * 100.0, - min_a + clamp_component(input[1], 0.0, 1.0) * (max_a - min_a), - min_b + clamp_component(input[2], 0.0, 1.0) * (max_b - min_b), - ) - } else { - (input[0], input[1], input[2]) - }; - let l = clamp_component(l, 0.0, 100.0); - let a = clamp_component(a, min_a, max_a); - let b = clamp_component(b, min_b, max_b); - // L*a*b* is relative to the space's own /WhitePoint. Map it to XYZ // in that white's coordinates, Bradford-adapt to D65 and convert to // sRGB (ISO 32000-2 §8.6.5.4). - let fy = (l + 16.0) / 116.0; - let fx = fy + a / 500.0; - let fz = fy - b / 200.0; - let xyz = [ - xw * Self::inverse_lab_f(fx), - yw * Self::inverse_lab_f(fy), - zw * Self::inverse_lab_f(fz), - ]; + let xyz = self.xyz(self.components(input, manual_scale)); let xyz_d65 = Self::adapt_to_d65(&white_point, &xyz); let srgb = CalRgb::matrix_product(&CalRgb::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65); @@ -833,6 +889,8 @@ pub(crate) struct Separation { alternate_space: ColorSpace, tint_transform: Function, is_none_separation: bool, + /// PdfCraft patch (35): the colorant the space paints, for separations. + colorant: Colorant, } impl Separation { @@ -846,17 +904,20 @@ impl Separation { // Either I did something wrong, or no other viewers properly handles // `All`, so let's just ignore it as well. let is_none_separation = name.as_str() == "None"; + let colorant = Colorant::from_name(name.deref()); Some(Self { alternate_space, tint_transform, is_none_separation, + colorant, }) } } impl ToRgb for Separation { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + count_tint_transform_runs(input.len()); let evaluated = input .iter() .flat_map(|n| { @@ -879,6 +940,8 @@ pub(crate) struct DeviceN { num_components: u8, tint_transform: Function, is_none: bool, + /// PdfCraft patch (35): the colorant of each component, for separations. + colorants: Vec, } impl DeviceN { @@ -900,17 +963,30 @@ impl DeviceN { return None; } + // `All` names every colorant only in a Separation (ISO 32000-2 §8.6.6.5), so a DeviceN + // component named `All` paints nothing in particular. + let colorants = names + .iter() + .map(|n| match Colorant::from_name(n.deref()) { + Colorant::All => Colorant::None, + c => c, + }) + .collect(); + Some(Self { alternate_space, num_components, tint_transform, is_none: all_none, + colorants, }) } } impl ToRgb for DeviceN { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + let colours = input.len().checked_div(usize::from(self.num_components)); + count_tint_transform_runs(colours.unwrap_or(0)); let evaluated = input .chunks_exact(self.num_components as usize) .flat_map(|n| { @@ -935,6 +1011,12 @@ struct ICCColorRepr { is_lab: bool, transform_u8: Arc, transform_f32: OnceLock>, + /// PdfCraft patch (36): a digest of the profile's bytes, to tell a colour in a + /// [`CmykSimulation`]'s own profile from one in another. + digest: u128, + /// PdfCraft patch (36): the transform into the separation hub (see [`Hub`]), made on first + /// use; `None` when the profile doesn't convert to it. + to_hub: OnceLock>>, } #[derive(Clone)] @@ -958,7 +1040,13 @@ impl ICCProfile { .unwrap_or(false); let is_lab = src_profile.color_space == DataColorSpace::Lab; - Self::new_from_src_profile(src_profile, is_srgb, is_lab, number_components) + Self::new_from_src_profile( + src_profile, + is_srgb, + is_lab, + number_components, + hash128(profile), + ) } fn new_from_src_profile( @@ -966,6 +1054,7 @@ impl ICCProfile { is_srgb: bool, is_lab: bool, number_components: usize, + digest: u128, ) -> Option { let src_layout = match number_components { 1 => Layout::Gray, @@ -997,9 +1086,52 @@ impl ICCProfile { is_lab, transform_u8, transform_f32: OnceLock::new(), + digest, + to_hub: OnceLock::new(), }))) } + /// PdfCraft patch (36): `input` (whole colors of this profile's components) as XYZ + /// relative to [`PCS_WHITE`], three values per color 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 + .get_or_init(|| { + transform_f32( + &self.0.src_profile, + self.0.src_layout, + &HUB.profile, + Layout::Rgb, + ) + .map(|(transform, _)| transform) + }) + .as_ref()?; + let n = self.0.number_components; + let samples = input.len() / n.max(1); + if samples > SIMULATION_CHUNK || xyz.len() != samples.checked_mul(3)? { + return None; + } + // Values outside the encoding (or NaN) never reach the tables. + let mut encoded = [0.0_f32; SIMULATION_CHUNK * 4]; + let encoded = encoded.get_mut(..samples.checked_mul(n)?)?; + for (e, (k, v)) in encoded.iter_mut().zip(input.iter().enumerate()) { + *e = match (self.is_lab() && !manual_scale, k % 3) { + (true, 0) => unit(*v / 100.0), + (true, _) => unit((*v + 128.0) / 255.0), + (false, _) => unit(*v), + }; + } + transform.transform(encoded, xyz).ok()?; + for color in xyz.chunks_exact_mut(3) { + let pcs = HUB.to_pcs(&[color[0], color[1], color[2]]); + color.copy_from_slice(&pcs); + } + Some(()) + } + fn is_srgb(&self) -> bool { self.0.is_srgb } @@ -1122,6 +1254,955 @@ impl Color { opacity: c[3], } } + + /// PdfCraft patch (35): the gray film of `tint` on a plate (see [`Plate`]). + pub(crate) fn film(tint: f32, opacity: f32) -> Self { + Self::new( + ColorSpace::device_gray(), + smallvec![1.0 - unit(tint)], + opacity, + ) + } + + /// PdfCraft patch (35): this colour as drawn on `plate`. `overprint` is the graphics + /// state's overprint parameter for this paint (`OP` for strokes, `op` otherwise) and + /// `overprint_mode` its `OPM` (ISO 32000-2 §8.6.7). A paint that leaves the plate as it is + /// becomes fully transparent. + pub(crate) fn for_plate(&self, plate: &Plate, overprint: bool, overprint_mode: u8) -> Self { + let overprint = plate.overprint && overprint; + let untouched = Self::film(0.0, 0.0); + match self.color_space.ink_use(&plate.ink) { + InkUse::Nothing => untouched, + InkUse::Unspecified if overprint => untouched, + InkUse::Unspecified => Self::film(0.0, self.opacity), + InkUse::Paints => { + let tint = self.color_space.ink_tint( + &self.components, + &plate.ink, + plate.simulation.as_ref(), + false, + ); + // Nonzero overprint mode: a DeviceCMYK component of 0 leaves its plate as it + // is. It applies to colours set in DeviceCMYK itself, not to images, shadings + // or colours converted from another space. + if overprint + && overprint_mode == 1 + && self.color_space.is_device_cmyk() + && tint <= 0.0 + { + untouched + } else { + Self::film(tint, self.opacity) + } + } + } + } +} + +/// PdfCraft patch (35): a printing ink, the unit of a separation: one of the four process +/// colorants or a spot colorant named by a `Separation` or `DeviceN` colour space (ISO 32000-2 +/// §8.6.6.4). +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Ink { + /// Process cyan. + Cyan, + /// Process magenta. + Magenta, + /// Process yellow. + Yellow, + /// Process black. + Black, + /// A spot colorant, by its name in the colour space. + Spot(Arc), +} + +impl Ink { + /// The four process inks, in plate order. + pub const PROCESS: [Ink; 4] = [Ink::Cyan, Ink::Magenta, Ink::Yellow, Ink::Black]; + + /// The ink's colorant name, as a `Separation` colour space spells it. + pub fn name(&self) -> &str { + match self { + Self::Cyan => "Cyan", + Self::Magenta => "Magenta", + Self::Yellow => "Yellow", + Self::Black => "Black", + Self::Spot(name) => name, + } + } + + /// The ink's position in a CMYK colour, for a process ink. + pub(crate) fn process_index(&self) -> Option { + match self { + Self::Cyan => Some(0), + Self::Magenta => Some(1), + Self::Yellow => Some(2), + Self::Black => Some(3), + Self::Spot(_) => None, + } + } +} + +/// PdfCraft patch (35): what a `Separation` or `DeviceN` colorant name paints (ISO 32000-2 +/// §8.6.6.4): an ink (the process names are the process inks), every ink (`All`), or nothing +/// (`None`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Colorant { + Ink(Ink), + All, + None, +} + +impl Colorant { + fn from_name(name: &[u8]) -> Self { + match name { + b"Cyan" => Self::Ink(Ink::Cyan), + b"Magenta" => Self::Ink(Ink::Magenta), + b"Yellow" => Self::Ink(Ink::Yellow), + b"Black" => Self::Ink(Ink::Black), + b"All" => Self::All, + b"None" => Self::None, + other => Self::Ink(Ink::Spot(Arc::from( + String::from_utf8_lossy(other).as_ref(), + ))), + } + } +} + +/// PdfCraft patch (35): draw one ink's separation instead of the composite page. Every paint +/// draws the ink's tint as a gray film: white where the plate carries no ink, black where it +/// carries 100%. Set through [`InterpreterSettings::plate`](crate::InterpreterSettings::plate). +#[derive(Clone, Debug, PartialEq)] +pub struct Plate { + /// The ink whose separation is drawn. + pub ink: Ink, + /// Honour the overprint parameters `OP`, `op` and `OPM` (ISO 32000-2 §8.6.7) as a + /// separating device does: a paint whose colour space doesn't specify this ink leaves the + /// plate as it is. When `false`, every paint knocks out what is under it, as on a + /// composite device. + pub overprint: bool, + /// PdfCraft patch (36): the printing condition process colour converts to, from the + /// document's output intent. `None` converts by the ISO 32000-2 §10.4.2 device formulas. + pub simulation: Option, +} + +/// PdfCraft patch (35): told about each spot ink a paint uses, with the ink's appearance: the +/// sRGB colour of that ink alone at a tint in `0.0..=1.0`, through the colour space's +/// alternate space and tint transform. +pub type InkSinkFn = Arc [u8; 3]) + Send + Sync>; + +/// PdfCraft patch (35): how a colour space paints one ink's plate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InkUse { + /// The space specifies the ink: its colours set the plate's tint. + Paints, + /// The space doesn't specify the ink. Overprinting leaves the plate alone; otherwise the + /// paint knocks it out (tint 0). + Unspecified, + /// The space paints nothing at all (the `None` colorant). + Nothing, +} + +/// PdfCraft patch (35): DeviceCMYK to sRGB as pages are drawn (the bundled CGATS-compatible +/// profile), to compose separations back into a preview. `cmyk` holds whole pixels of four +/// bytes and `rgb` the same number of three-byte pixels; `None` when the lengths disagree or +/// the transform fails. +pub fn cmyk_to_srgb8(cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { + return None; + } + CMYK_TRANSFORM.convert_u8(cmyk, rgb) +} + +/// A component as a tint: clamped to `0.0..=1.0`, NaN read as no ink. +fn unit(value: f32) -> f32 { + clamp_component(value, 0.0, 1.0) +} + +/// The DeviceCMYK colour of a DeviceRGB one by ISO 32000-2 §10.4.2.4, with black generation +/// and undercolour removal both the identity (`BG(k) = UCR(k) = k`). Those two functions are +/// device dependent; the identity is the simplest choice the formulas allow. +fn rgb_to_cmyk([r, g, b]: [f32; 3]) -> [f32; 4] { + let (c, m, y) = (1.0 - unit(r), 1.0 - unit(g), 1.0 - unit(b)); + let k = c.min(m).min(y); + [unit(c - k), unit(m - k), unit(y - k), unit(k)] +} + +/// PdfCraft patch (36): colours converted per step through a [`CmykSimulation`], so that the +/// buffers stay on the stack whatever an image's size. +const SIMULATION_CHUNK: usize = 256; + +/// PdfCraft patch (36): the printing condition separations simulate, from an output intent's +/// destination profile (ISO 32000-2 §14.11.5): a CMYK ICC profile with PCS-to-device (BToA) +/// tables. Set through [`Plate::simulation`]. Process colour in other spaces converts to the +/// profile's CMYK through those tables, instead of by the device formulas of §10.4.2: +/// +/// - 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 +/// 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 [`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 { + digest: u128, + conversion: Box, +} + +/// The conversion into a [`CmykSimulation`]'s CMYK, relative colorimetric. Each call converts +/// whole colors, at most 256 of them; CMYK components are `0.0..=1.0`, four per color. A +/// method returns `None` when it can't convert, and the color then separates by the device +/// formulas. +pub trait CmykConversion: Send + Sync { + /// sRGB colors, gamma-encoded, three components in `0.0..=1.0` per color, to CMYK. + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()>; + + /// CIE XYZ colors relative to [`PCS_WHITE`] (the Y of white is 1), three components per + /// color, 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 colors 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.description()) + .field("perceptual", &self.perceptual()) + .finish_non_exhaustive() + } +} + +/// Two simulations of the same profile are the same. +impl PartialEq for CmykSimulation { + fn eq(&self, other: &Self) -> bool { + self.0.digest == other.0.digest + } +} + +/// PdfCraft patch (36): why an ICC profile can't be simulated. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoSimulation { + /// The profile doesn't parse. + Unreadable, + /// Its data colour space isn't CMYK. + NotCmyk, + /// It has no PCS-to-device (BToA) tables, or they don't convert. + NoPcsToDevice, +} + +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 colors 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 colors +/// 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; + + 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); + } + if parsed.lut_b_to_a_colorimetric.is_none() && parsed.lut_b_to_a_perceptual.is_none() { + return Err(NoSimulation::NoPcsToDevice); + } + let (from_hub, perceptual) = + transform_f32(&HUB.profile, Layout::Rgb, &parsed, Layout::Rgba) + .ok_or(NoSimulation::NoPcsToDevice)?; + 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 { + description: profile_description(&parsed, Self::MAX_DESCRIPTION), + perceptual, + 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() + } + + 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.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 + } +} + +/// PdfCraft patch (36): a profile's description, without control characters and at most `max` +/// characters long; `None` when it has none. +fn profile_description(profile: &ColorProfile, max: usize) -> Option { + let text = match profile.description.as_ref()? { + ProfileText::PlainString(s) => s.as_str(), + ProfileText::Localizable(list) => { + &list + .iter() + .find(|s| s.language == "en") + .or(list.first())? + .value + } + ProfileText::Description(d) if d.ascii_string.trim().is_empty() => &d.unicode_string, + ProfileText::Description(d) => &d.ascii_string, + }; + let clean: String = text + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(max) + .collect(); + let clean = clean.trim(); + (!clean.is_empty()).then(|| clean.to_string()) +} + +/// PdfCraft patch (36): a transform from `source` to `destination`, relative colorimetric, or +/// through the perceptual tables (`true`) where a profile lacks colorimetric ones. +fn transform_f32( + source: &ColorProfile, + source_layout: Layout, + destination: &ColorProfile, + destination_layout: Layout, +) -> Option<(Arc, bool)> { + [ + RenderingIntent::RelativeColorimetric, + RenderingIntent::Perceptual, + ] + .into_iter() + .find_map(|rendering_intent| { + let options = TransformOptions { + rendering_intent, + ..TransformOptions::default() + }; + source + .create_transform_f32(source_layout, destination, destination_layout, options) + .ok() + .map(|t| (t, rendering_intent == RenderingIntent::Perceptual)) + }) +} + +/// PdfCraft patch (36): as [`transform_f32`], for bytes. +fn transform_u8( + source: &ColorProfile, + source_layout: Layout, + destination: &ColorProfile, + destination_layout: Layout, +) -> Option> { + [ + RenderingIntent::RelativeColorimetric, + RenderingIntent::Perceptual, + ] + .into_iter() + .find_map(|rendering_intent| { + let options = TransformOptions { + rendering_intent, + ..TransformOptions::default() + }; + source + .create_transform_8bit(source_layout, destination, destination_layout, options) + .ok() + }) +} + +static SRGB: LazyLock = LazyLock::new(ColorProfile::new_srgb); + +/// PdfCraft patch (36): the separation hub, the RGB that ICCBased colors 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)`. + white: [f32; 3], +} + +impl Hub { + const GAMMA: f32 = 1.8; + + fn new() -> Self { + let profile = ColorProfile::new_pro_photo_rgb(); + 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, + to_xyz: flat(to_xyz.v), + from_xyz: flat(from_xyz), + white, + } + } + + /// 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]) + } +} + +static HUB: LazyLock = LazyLock::new(Hub::new); + +impl ColorSpace { + /// PdfCraft patch (35): this space drawn as `ink`'s plate (for images and shadings, whose + /// colours are converted sample by sample), converting process colour through + /// `simulation` (patch 36). + pub(crate) fn plate(&self, ink: Ink, simulation: Option) -> Self { + Self(Arc::new(ColorSpaceType::Plate(PlateSpace { + inner: self.clone(), + ink, + simulation, + }))) + } + + /// PdfCraft patch (36): whether `simulation` converts this space's colours (see + /// [`CmykSimulation`]). + fn simulated_by(&self, simulation: &CmykSimulation) -> bool { + match self.0.as_ref() { + ColorSpaceType::DeviceRgb + | ColorSpaceType::CalRgb(_) + | ColorSpaceType::CalGray(_) + | ColorSpaceType::Lab(_) => true, + ColorSpaceType::ICCBased(icc) => { + icc.0.number_components != 4 || icc.0.digest != simulation.0.digest + } + _ => false, + } + } + + /// PdfCraft patch (36): the colours `input` (whole colours of this space's components; + /// `manual_scale` as for [`ToRgb::convert_f32`]) in `simulation`'s CMYK, four values per + /// colour in `cmyk`. `None` when the space isn't [`simulated_by`](Self::simulated_by) + /// `simulation`, the lengths disagree, or a conversion fails. + fn simulate_cmyk( + &self, + simulation: &CmykSimulation, + input: &[f32], + manual_scale: bool, + cmyk: &mut [f32], + ) -> Option<()> { + let n = usize::from(self.num_components()).max(1); + let colours = input.len() / n; + 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 + .chunks(SIMULATION_CHUNK * n) + .zip(cmyk.chunks_mut(SIMULATION_CHUNK * 4)) + { + let count = cmyk.len() / 4; + let input = input.get(..count.checked_mul(n)?)?; + let rgb = rgb.get_mut(..count * 3)?; + match self.0.as_ref() { + ColorSpaceType::DeviceRgb => { + for (out, v) in rgb.iter_mut().zip(input) { + *out = unit(*v); + } + conversion.from_srgb(rgb, cmyk)?; + } + ColorSpaceType::CalRgb(_) | ColorSpaceType::CalGray(_) => { + let srgb = srgb.get_mut(..count * 3)?; + self.convert_f32(input, srgb, manual_scale)?; + for (out, v) in rgb.iter_mut().zip(srgb.iter()) { + *out = u8_to_f32(*v); + } + 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_pcs(colour, manual_scale)); + } + conversion.from_pcs_xyz(rgb, cmyk)?; + } + ColorSpaceType::ICCBased(icc) if self.simulated_by(simulation) => { + icc.to_pcs(input, manual_scale, rgb)?; + conversion.from_pcs_xyz(rgb, cmyk)?; + } + _ => return None, + } + } + Some(()) + } + + /// PdfCraft patch (35): DeviceCMYK itself (`CalCMYK` reads as it too), where the nonzero + /// overprint mode applies. + pub(crate) fn is_device_cmyk(&self) -> bool { + matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk) + } + + /// PdfCraft patch (35): spaces whose colours are converted to sRGB before separation + /// (DeviceRGB is separated from its components directly, without a conversion). + fn separates_through_srgb(&self) -> bool { + match self.0.as_ref() { + ColorSpaceType::CalRgb(_) | ColorSpaceType::Lab(_) => true, + ColorSpaceType::ICCBased(icc) => icc.0.number_components == 3, + _ => false, + } + } + + /// PdfCraft patch (35): how the space paints `ink`'s plate. Gray, RGB, CIE-based and ICC + /// spaces are process colour: they specify the four process inks and no spot ink. + pub(crate) fn ink_use(&self, ink: &Ink) -> InkUse { + match self.0.as_ref() { + ColorSpaceType::DeviceCmyk + | ColorSpaceType::DeviceGray + | ColorSpaceType::DeviceRgb + | ColorSpaceType::ICCBased(_) + | ColorSpaceType::CalGray(_) + | ColorSpaceType::CalRgb(_) + | ColorSpaceType::Lab(_) => { + if ink.process_index().is_some() { + InkUse::Paints + } else { + InkUse::Unspecified + } + } + ColorSpaceType::Indexed(i) => i.base.ink_use(ink), + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::None => InkUse::Nothing, + Colorant::All => InkUse::Paints, + Colorant::Ink(i) if i == ink => InkUse::Paints, + Colorant::Ink(_) => InkUse::Unspecified, + }, + ColorSpaceType::DeviceN(d) => { + if d.is_none { + InkUse::Nothing + } else if d + .colorants + .iter() + .any(|c| matches!(c, Colorant::Ink(i) if i == ink)) + { + InkUse::Paints + } else { + InkUse::Unspecified + } + } + // A pattern is separated through what it paints. + ColorSpaceType::Pattern(_) => InkUse::Unspecified, + ColorSpaceType::Plate(p) => p.inner.ink_use(ink), + } + } + + /// PdfCraft patch (35): the tint, `0.0` (no ink) to `1.0` (solid), that the colour + /// `components` puts on `ink`'s plate; `0.0` when the space doesn't specify the ink. + /// Process colour converts through `simulation` when there is one (patch 36). + /// `manual_scale` as for [`ToRgb::convert_f32`] (components normalised to `0.0..=1.0`, as + /// an Indexed palette gives them). + pub(crate) fn ink_tint( + &self, + components: &[f32], + ink: &Ink, + simulation: Option<&CmykSimulation>, + manual_scale: bool, + ) -> f32 { + let tint = match self.0.as_ref() { + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::All => components.first().copied().unwrap_or(0.0), + Colorant::Ink(i) if i == ink => components.first().copied().unwrap_or(0.0), + _ => 0.0, + }, + ColorSpaceType::DeviceN(d) => d + .colorants + .iter() + .position(|c| matches!(c, Colorant::Ink(i) if i == ink)) + .and_then(|k| components.get(k)) + .copied() + .unwrap_or(0.0), + ColorSpaceType::Indexed(i) => { + let index = components.first().copied().unwrap_or(0.0); + let index = (clamp_component(index, 0.0, f32::from(i.hival)) + 0.5) as usize; + match i.values.get(index) { + Some(values) => i.base.ink_tint(values, ink, simulation, true), + None => 0.0, + } + } + ColorSpaceType::Pattern(_) => 0.0, + ColorSpaceType::Plate(p) => p.inner.ink_tint(components, ink, simulation, manual_scale), + _ => match ( + self.process_cmyk(components, simulation, manual_scale), + ink.process_index(), + ) { + (Some(cmyk), Some(k)) => cmyk.get(k).copied().unwrap_or(0.0), + _ => 0.0, + }, + }; + unit(tint) + } + + /// 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 36: + /// the spaces `simulation` converts go through it instead, and only fall back to those + /// formulas if the conversion fails. + fn process_cmyk( + &self, + components: &[f32], + simulation: Option<&CmykSimulation>, + manual_scale: bool, + ) -> Option<[f32; 4]> { + if let Some(simulation) = simulation.filter(|s| self.simulated_by(s)) { + let n = usize::from(self.num_components()).max(1); + let mut colour: ColorComponents = components.iter().copied().take(n).collect(); + colour.resize(n, 0.0); + let mut cmyk = [0.0; 4]; + if self + .simulate_cmyk(simulation, &colour, manual_scale, &mut cmyk) + .is_some() + { + return Some(cmyk.map(unit)); + } + } + let get = |k: usize| unit(components.get(k).copied().unwrap_or(0.0)); + match self.0.as_ref() { + ColorSpaceType::DeviceCmyk => Some([get(0), get(1), get(2), get(3)]), + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 4 => { + Some([get(0), get(1), get(2), get(3)]) + } + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 1 => { + Some([0.0, 0.0, 0.0, 1.0 - get(0)]) + } + ColorSpaceType::DeviceGray | ColorSpaceType::CalGray(_) => { + Some([0.0, 0.0, 0.0, 1.0 - get(0)]) + } + ColorSpaceType::DeviceRgb => Some(rgb_to_cmyk([get(0), get(1), get(2)])), + _ if self.separates_through_srgb() => { + let mut rgb = [0_u8; 3]; + self.convert_sample(components, &mut rgb, manual_scale)?; + Some(rgb_to_cmyk(rgb.map(u8_to_f32))) + } + _ => None, + } + } + + /// PdfCraft patch (35): tell `sink` about every spot ink the space names, with the ink's + /// appearance (the space's colour with only that component set). + pub(crate) fn report_spots(&self, sink: &InkSinkFn) { + match self.0.as_ref() { + ColorSpaceType::Separation(s) => { + if let Colorant::Ink(ink @ Ink::Spot(_)) = &s.colorant { + sink(ink, &|tint| self.appearance(&[tint])); + } + } + ColorSpaceType::DeviceN(d) => { + for (k, colorant) in d.colorants.iter().enumerate() { + if let Colorant::Ink(ink @ Ink::Spot(_)) = colorant { + sink(ink, &|tint| { + let mut components: ColorComponents = smallvec![0.0; d.colorants.len()]; + if let Some(c) = components.get_mut(k) { + *c = tint; + } + self.appearance(&components) + }); + } + } + } + ColorSpaceType::Indexed(i) => i.base.report_spots(sink), + ColorSpaceType::Pattern(cs) => cs.report_spots(sink), + ColorSpaceType::Plate(p) => p.inner.report_spots(sink), + _ => {} + } + } + + /// The sRGB colour of `components` (the `None` colorant's invisibility aside). + fn appearance(&self, components: &[f32]) -> [u8; 3] { + let [r, g, b, _] = self.to_rgba(components, 1.0, false).to_rgba8(); + [r, g, b] + } +} + +/// PdfCraft patch (35): a colour space drawn as one ink's plate: each colour becomes the gray +/// film of that ink's tint (see [`Plate`]). +#[derive(Debug, Clone)] +pub(crate) struct PlateSpace { + inner: ColorSpace, + ink: Ink, + /// Patch 36: the printing condition process colour converts to, if any. + simulation: Option, +} + +impl PlateSpace { + fn samples(&self) -> usize { + usize::from(self.inner.num_components()).max(1) + } + + /// The film of the tint the colour `sample` puts on the plate. + fn film(&self, sample: &[f32], manual_scale: bool) -> u8 { + let tint = self + .inner + .ink_tint(sample, &self.ink, self.simulation.as_ref(), manual_scale); + f32_to_u8(1.0 - tint) + } + + /// PdfCraft patch (36): the inner space's colours `input` converted through the + /// simulation, as film; `None` when the space isn't simulated or a conversion fails. + fn film_through_simulation( + &self, + input: &[f32], + output: &mut [u8], + manual_scale: bool, + ) -> Option<()> { + let simulation = self + .simulation + .as_ref() + .filter(|s| self.inner.simulated_by(s))?; + let Some(k) = self.ink.process_index() else { + output.fill(255); + return Some(()); + }; + let n = self.samples(); + let mut cmyk = [0.0_f32; SIMULATION_CHUNK * 4]; + for (input, rgb) in input + .chunks(SIMULATION_CHUNK * n) + .zip(output.chunks_mut(SIMULATION_CHUNK * 3)) + { + let count = (input.len() / n).min(rgb.len() / 3); + let cmyk = cmyk.get_mut(..count * 4)?; + self.inner + .simulate_cmyk(simulation, input.get(..count * n)?, manual_scale, cmyk)?; + for (px, colour) in rgb.chunks_exact_mut(3).zip(cmyk.chunks_exact(4)) { + px.fill(f32_to_u8(1.0 - unit(colour.get(k).copied().unwrap_or(0.0)))); + } + } + Some(()) + } + + /// The film of every entry of an Indexed inner space's palette, worked out once for an + /// image's `samples` rather than once per sample; `None` for other spaces, and when there + /// are no more samples than palette entries. + fn palette_films(&self, samples: usize) -> Option> { + let ColorSpaceType::Indexed(indexed) = self.inner.0.as_ref() else { + return None; + }; + if samples <= usize::from(indexed.hival) + 1 { + return None; + } + Some( + (0..=indexed.hival) + .map(|index| self.film(&[f32::from(index)], false)) + .collect(), + ) + } + + /// Turn sRGB pixels, converted from the inner space, into film in place. + fn film_from_srgb(&self, output: &mut [u8]) { + let Some(k) = self.ink.process_index() else { + output.fill(255); + return; + }; + for rgb in output.chunks_exact_mut(3) { + let cmyk = rgb_to_cmyk([u8_to_f32(rgb[0]), u8_to_f32(rgb[1]), u8_to_f32(rgb[2])]); + let film = f32_to_u8(1.0 - cmyk.get(k).copied().unwrap_or(0.0)); + rgb.copy_from_slice(&[film; 3]); + } + } +} + +impl ToRgb for PlateSpace { + fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> { + if self + .film_through_simulation(input, output, manual_scale) + .is_some() + { + return Some(()); + } + if self.inner.separates_through_srgb() { + self.inner.convert_f32(input, output, manual_scale)?; + self.film_from_srgb(output); + return Some(()); + } + if let Some(films) = self.palette_films(input.len()) { + // As `ink_tint` reads an Indexed sample: the index, clamped and rounded. + let hival = films.len().saturating_sub(1) as f32; + for (index, rgb) in input.iter().zip(output.chunks_exact_mut(3)) { + let index = (clamp_component(*index, 0.0, hival) + 0.5) as usize; + rgb.fill(films.get(index).copied().unwrap_or(255)); + } + return Some(()); + } + for (sample, rgb) in input + .chunks_exact(self.samples()) + .zip(output.chunks_exact_mut(3)) + { + rgb.fill(self.film(sample, manual_scale)); + } + Some(()) + } + + // The spaces that take bytes (device and ICC spaces) read them as component × 255. + fn supports_u8(&self) -> bool { + self.inner.supports_u8() + } + + fn convert_u8(&self, input: &[u8], output: &mut [u8]) -> Option<()> { + if self + .simulation + .as_ref() + .is_some_and(|s| self.inner.simulated_by(s)) + { + // Bytes are the components' encoding scaled to 0–255 (an ICC L*a*b* colour's too, + // so they arrive as `manual_scale` components). + let n = self.samples(); + let mut floats = [0.0_f32; SIMULATION_CHUNK * 4]; + let mut done = Some(()); + for (bytes, rgb) in input + .chunks(SIMULATION_CHUNK * n) + .zip(output.chunks_mut(SIMULATION_CHUNK * 3)) + { + let floats = floats.get_mut(..bytes.len())?; + for (f, b) in floats.iter_mut().zip(bytes) { + *f = u8_to_f32(*b); + } + done = done.and(self.film_through_simulation(floats, rgb, true)); + } + if done.is_some() { + return Some(()); + } + } + if self.inner.separates_through_srgb() { + self.inner.convert_u8(input, output)?; + self.film_from_srgb(output); + return Some(()); + } + let mut sample: ColorComponents = SmallVec::new(); + for (bytes, rgb) in input + .chunks_exact(self.samples()) + .zip(output.chunks_exact_mut(3)) + { + sample.clear(); + sample.extend(bytes.iter().copied().map(u8_to_f32)); + rgb.fill(self.film(&sample, false)); + } + Some(()) + } + + fn is_none(&self) -> bool { + self.inner.is_none() + } } static CMYK_TRANSFORM: LazyLock = LazyLock::new(|| { diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 9953b419..3614aee8 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -150,6 +150,12 @@ impl<'a> InterpreterCache<'a> { decoded_cache: DecodedImageCache::new(), } } + + /// PdfCraft patch (32): how many images were decoded through this cache (its misses, not + /// counting images that are never cached), for diagnostics and tests. + pub fn decoded_image_decodes(&self) -> usize { + self.decoded_cache.decodes() + } } /// A per-page interpretation context that borrows shared data from an [`InterpreterCache`]. diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 72d5a980..57d11be2 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -66,6 +66,47 @@ pub trait Device<'a> { fn actual_text(&mut self, _text: &[u8]) {} /// Called at the end of a marked content sequence (EMC). fn end_marked_content(&mut self) {} + /// PdfCraft patch (29): the page's annotations are about to be drawn (until + /// `end_annotations`), as page content finishes. Devices can use this to treat their + /// paints as annotation colours rather than document colours — the reading-colours + /// setting replaces document colours only, never the colours a comment was given. + fn begin_annotations(&mut self) {} + /// PdfCraft patch (29): the page's annotations are done; what follows is page content. + fn end_annotations(&mut self) {} + /// Knockout patch (pdfcraft #2974): the transparency group just pushed is a knockout group + /// (`/K true`): each of its elements composites with the group's initial backdrop, not with the + /// elements drawn before it in the group (ISO 32000-2, knockout groups), until the matching + /// `pop_transparency_group`. + fn set_knockout(&mut self) {} + /// Knockout patch (pdfcraft #2974): an element whose shape is `shape` (device space) is about + /// to be drawn into a transparency group of its own. In a knockout group (`set_knockout`) the + /// element replaces what the group drew under its shape, so a device clears that first; the + /// element's layer then composites over the cleared area as usual. + fn knockout_shape(&mut self, _shape: &BezPath) {} + /// Knockout patch (pdfcraft #2974): whether `knockout_shape` would clear anything now, that is + /// whether the innermost transparency group is a knockout group. Lets the interpreter skip work + /// it only needs for the clear. + fn in_knockout_group(&mut self) -> bool { + false + } + /// Knockout patch (pdfcraft #2974): `image`, an unmasked raster image, is about to be drawn + /// with `transform` in a knockout group. A device that will draw it (it isn't skipped and it + /// decodes) clears what the group drew under its placement rectangle first, so the image + /// replaces it. Decided by the device, at the size it draws the image, so the draw reuses that + /// decode. + fn knockout_image(&mut self, _image: &crate::RasterImage<'_>, _transform: Affine) {} + /// Knockout patch (pdfcraft #2974): whether a transparency group drawn now is an element of a + /// knockout group that the device wants cleared under the group's shape first. If so the + /// interpreter draws the group's content once between `begin_knockout_erase` and + /// `end_knockout_erase`, in which every paint clears what is under its shape instead of + /// painting, and then draws the group as usual, in a layer of its own. + fn knockout_erase_wanted(&mut self) -> bool { + false + } + /// Knockout patch (pdfcraft #2974): see `knockout_erase_wanted`. + fn begin_knockout_erase(&mut self) {} + /// Knockout patch (pdfcraft #2974): see `knockout_erase_wanted`. + fn end_knockout_erase(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index 498ed909..b3f830ba 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -3,7 +3,7 @@ use crate::color::{AlphaColor, ColorComponents, ColorSpace}; use crate::interpret::state::ActiveTransferFunction; use crate::pattern::ShadingPattern; -use crate::shading::{ShadingFunction, ShadingType, Triangle}; +use crate::shading::{ShadingFunction, ShadingType, Triangle, TriangleVertex}; use kurbo::{Affine, Point}; use rustc_hash::FxHashMap; use smallvec::{ToSmallVec, smallvec}; @@ -102,7 +102,7 @@ impl ShadingPattern { } triangles.clear(); patch.to_triangles(&mut triangles); - if !sampler.sample(&triangles, full_transform) { + if !sampler.sample_owned(&mut triangles, full_transform) { break; } } @@ -126,7 +126,7 @@ impl ShadingPattern { } triangles.clear(); patch.to_triangles(&mut triangles); - if !sampler.sample(&triangles, full_transform) { + if !sampler.sample_owned(&mut triangles, full_transform) { break; } } @@ -268,47 +268,62 @@ impl TriangleSampler { /// Sample `triangles`; `false` once a budget is spent (the rest is left unsampled). fn sample(&mut self, triangles: &[Triangle], transform: Affine) -> bool { + if !self.take_page_budget(triangles.len()) { + return false; + } + triangles.iter().all(|t| { + self.sample_one(transformed( + t.p0.clone(), + t.p1.clone(), + t.p2.clone(), + transform, + )) + }) + } + + /// PdfCraft patch (33): [`Self::sample`] for triangles made for this sampling alone (a + /// patch's), moving their vertices instead of copying each one's colours (`triangles` is + /// left empty). + fn sample_owned(&mut self, triangles: &mut Vec, transform: Affine) -> bool { + if !self.take_page_budget(triangles.len()) { + return false; + } + triangles + .drain(..) + .all(|t| self.sample_one(transformed(t.p0, t.p1, t.p2, transform))) + } + + /// Take `count` triangles from the page's budget; `false` (and none left) when it is short. + fn take_page_budget(&mut self, count: usize) -> bool { let page_left = MESH_TRIANGLES_LEFT.with(core::cell::Cell::get); - if page_left < triangles.len() as u64 { + if page_left < count as u64 { MESH_TRIANGLES_LEFT.with(|n| n.set(0)); warn!("page mesh triangle budget exceeded; the rest of the shading is not drawn"); return false; } - MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - triangles.len() as u64)); - for t in triangles { - let t = { - let p0 = transform * t.p0.point; - let p1 = transform * t.p1.point; - let p2 = transform * t.p2.point; + MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - count as u64)); + true + } - let mut v0 = t.p0.clone(); - v0.point = p0; - let mut v1 = t.p1.clone(); - v1.point = p1; - let mut v2 = t.p2.clone(); - v2.point = p2; + /// Sample one triangle in device space; `false` once the visit budget is spent. + fn sample_one(&mut self, t: Triangle) -> bool { + // Only the part of the triangle inside `area`; only such triangles add visits. + let bbox = t.bounding_box().intersect(self.area); + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return true; + } + self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); - Triangle::new(v0, v1, v2) - }; - - // Only the part of the triangle inside `area`; only such triangles add visits. - let bbox = t.bounding_box().intersect(self.area); - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { - continue; - } - self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); - - for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { - for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { - if self.visits_left == 0 { - warn!("mesh shading visit budget exceeded; the rest is not drawn"); - return false; - } - self.visits_left -= 1; - let point = Point::new(x as f64, y as f64); - if t.contains_point(point) { - self.map.insert((x, y), t.interpolate(point)); - } + for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { + for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { + if self.visits_left == 0 { + warn!("mesh shading visit budget exceeded; the rest is not drawn"); + return false; + } + self.visits_left -= 1; + let point = Point::new(x as f64, y as f64); + if t.contains_point(point) { + self.map.insert((x, y), t.interpolate(point)); } } } @@ -316,6 +331,20 @@ impl TriangleSampler { } } +/// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as +/// they are). +fn transformed( + mut v0: TriangleVertex, + mut v1: TriangleVertex, + mut v2: TriangleVertex, + transform: Affine, +) -> Triangle { + v0.point = transform * v0.point; + v1.point = transform * v1.point; + v2.point = transform * v2.point; + Triangle::new(v0, v1, v2) +} + fn encode_function_shading(domain: &[f32; 4], function: &ShadingFunction) -> EncodedShadingType { let domain = kurbo::Rect::new( domain[0] as f64, diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 5ccf5c48..7c8c8d08 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -444,6 +444,29 @@ impl Encoding { Self::BuiltIn => None, } } + + /// PdfCraft patch: the glyph name an Annex D.2 *alias code* reads as when the glyph is taken + /// as text rather than looked up in a font program; `None` for every other code. + /// + /// Annex D.2 gives WinAnsiEncoding codes 0xA0 and 0xAD the glyph names `space` and `hyphen`, + /// the same names it gives 0x20 and 0x2D, and MacRomanEncoding 0xCA `space`. The tables above + /// call them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are + /// *formatting* characters, so extraction hid ink the reader can see: a soft hyphen is + /// invisible, and text that drew one matched no search and no redaction pattern -- one corpus + /// file alone produced 104 of them -- while a non-breaking space did not match the space a + /// reader typed. pdftotext reads both codes as the ordinary character. + /// + /// Only the base table is aliased, in the text direction only. A `/Differences` entry naming + /// `sfthyphen` or `nbspace` is a deliberate statement and keeps its glyph list value, which + /// is what pdftotext does too; and glyph *lookup* is untouched, so a font program's own + /// `sfthyphen` program is still found first. + fn text_alias(&self, code: u8) -> Option<&'static str> { + match (self, code) { + (Self::WinAnsi, 0xA0) | (Self::MacRoman, 0xCA) => Some("space"), + (Self::WinAnsi, 0xAD) => Some("hyphen"), + _ => None, + } + } } /// The font stretch. diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index c73c75dd..139454a0 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -546,6 +546,14 @@ impl StandardKind { } pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 40f33590..b3626698 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -286,7 +286,13 @@ impl EmbeddedKind { self.differences .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // The last resort reads the code as WinAnsi whatever the font said, so it takes the + // same aliasing. + .or_else(|| Encoding::WinAnsi.text_alias(code)) .or_else(|| win_ansi::get(code)) } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 32b5e3dd..02f76bfb 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -239,6 +239,14 @@ impl Type1Kind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } @@ -336,6 +344,14 @@ impl CffKind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index 6824d4ff..216f2618 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -108,6 +108,9 @@ impl<'a> Type3<'a> { .encodings .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; + // see `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)); name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) @@ -165,9 +168,10 @@ impl<'a> Type3<'a> { is_shape_glyph }; + // PdfCraft patch: context clips and shading bounds are in device space; FontBBox is in glyph space. let mut context = Context::new_with( state.ctm, - self.font_bbox, + root_transform.transform_rect_bbox(self.font_bbox), &glyph.cache, glyph.xref, glyph.settings.clone(), diff --git a/vendor/hayro-interpret/src/function/type0.rs b/vendor/hayro-interpret/src/function/type0.rs index 02bbc41b..9a82c597 100644 --- a/vendor/hayro-interpret/src/function/type0.rs +++ b/vendor/hayro-interpret/src/function/type0.rs @@ -3,14 +3,18 @@ use hayro_syntax::bit_reader::BitReader; use hayro_syntax::object::Array; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::{BITS_PER_SAMPLE, DECODE, ENCODE, SIZE}; -use smallvec::{SmallVec, ToSmallVec, smallvec}; -use std::collections::HashMap; +use smallvec::SmallVec; /// A type 0 function (sampled function). #[derive(Debug)] pub(crate) struct Type0 { sizes: IntVec, - table: HashMap, + /// PdfCraft patch (31): the samples in stream order, `range.len()` to a grid point, the + /// first input varying fastest. They were a hash map keyed by each grid point, built again + /// every time a shading using the function was painted. + samples: Vec, + /// PdfCraft patch (31): how many grid points one step along each input moves. + strides: SmallVec<[usize; 4]>, clamper: Clamper, range: TupleVec, bits_per_sample: u8, @@ -85,14 +89,22 @@ impl Type0 { data.truncate(num_expected_entries); } - let table = build_table(&data, &sizes, range.len())?; + // Saturating: the grid has at most `MAX_SAMPLE_ENTRIES` points unless a size is 0, and + // then no point is on it (`entry` checks each coordinate against its size first). + let mut strides = SmallVec::with_capacity(sizes.len()); + let mut stride = 1usize; + for size in &sizes { + strides.push(stride); + stride = stride.saturating_mul(*size as usize); + } Some(Self { sizes, clamper, range, bits_per_sample, - table, + samples: data, + strides, encode, decode, }) @@ -122,29 +134,16 @@ impl Type0 { let in_prev = key.iter().map(|v| v.floor() as u32).collect::(); let in_next = key.iter().map(|v| v.ceil() as u32).collect::(); + let mut coord: IntVec = in_prev.clone(); - let interpolator = Interpolator::new( - key.clone().to_smallvec(), - in_prev, - in_next, - self.sizes.clone(), - self.range.len(), - ); - - let interpolated = interpolator.interpolate(&self.table)?; + let interpolated = self.interpolate(&key, &in_prev, &in_next, &mut coord, 0)?; + // PdfCraft patch (31): `2_u32.pow(32)` overflowed (a panic with overflow checks on). + let max_sample = ((1u64 << self.bits_per_sample) - 1) as f32; let mut out = interpolated .iter() .zip(self.decode.iter()) - .map(|(x, decode)| { - interpolate( - *x, - 0.0, - (2_u32.pow(self.bits_per_sample as u32) - 1) as f32, - decode.0, - decode.1, - ) - }) + .map(|(x, decode)| interpolate(*x, 0.0, max_sample, decode.0, decode.1)) .collect::>(); self.clamper.clamp_output(&mut out); @@ -157,95 +156,57 @@ type FloatVec = SmallVec<[f32; 4]>; type IntVec = SmallVec<[u32; 4]>; // See -struct Interpolator { - input: FloatVec, - sizes: IntVec, - in_prev: IntVec, - in_next: IntVec, - out_len: usize, -} - -impl Interpolator { - fn new( - input: FloatVec, - in_prev: IntVec, - in_next: IntVec, - sizes: IntVec, - out_len: usize, - ) -> Self { - Self { - input, - in_prev, - in_next, - sizes, - out_len, - } - } - - fn interpolate(&self, table: &HashMap) -> Option { - self.interpolate_inner(smallvec![0; self.input.len()], 0, table) - } - - fn interpolate_inner( +impl Type0 { + /// PdfCraft patch (31): the value at `input` between the grid points `in_prev` and + /// `in_next`, interpolating the inputs from `step` on (the earlier ones are placed in + /// `coord`). The same multilinear interpolation, in the same order, as the hash-map + /// version, so the results are bit for bit the same; one level of recursion per input, + /// as before. + fn interpolate( &self, - mut coord: IntVec, + input: &[f32], + in_prev: &[u32], + in_next: &[u32], + coord: &mut IntVec, step: usize, - table: &HashMap, ) -> Option { - if step == self.input.len() - 1 { - if self.in_prev[step] == self.in_next[step] { - coord[step] = self.in_prev[step]; + let (Some(x), Some(prev), Some(next)) = + (input.get(step), in_prev.get(step), in_next.get(step)) + else { + // Every input placed: the grid point itself. + return Some(self.entry(coord)?.iter().map(|n| *n as f32).collect()); + }; + let (x, prev, next) = (*x, *prev, *next); - Some( - table - .get(&Key::from_raw(&self.sizes, &coord))? - .clone() - .iter() - .map(|n| *n as f32) - .collect(), - ) - } else { - coord[step] = self.in_prev[step]; - let val1 = table.get(&Key::from_raw(&self.sizes, &coord))?; - coord[step] = self.in_next[step]; - let val2 = table.get(&Key::from_raw(&self.sizes, &coord))?; - let mut out = smallvec![0.0; self.out_len]; - - for i in 0..self.out_len { - out[i] = interpolate( - self.input[step], - self.in_prev[step] as f32, - self.in_next[step] as f32, - val1[i] as f32, - val2[i] as f32, - ); - } - - Some(out) - } - } else if self.in_prev[step] == self.in_next[step] { - coord[step] = self.in_prev[step]; - self.interpolate_inner(coord, step + 1, table) - } else { - coord[step] = self.in_prev[step]; - let val1 = self.interpolate_inner(coord.clone(), step + 1, table)?; - coord[step] = self.in_next[step]; - let val2 = self.interpolate_inner(coord, step + 1, table)?; - - let mut out = smallvec![0.0; self.out_len]; - - for i in 0..self.out_len { - out[i] = interpolate( - self.input[step], - self.in_prev[step] as f32, - self.in_next[step] as f32, - val1[i], - val2[i], - ); - } - - Some(out) + *coord.get_mut(step)? = prev; + let val1 = self.interpolate(input, in_prev, in_next, coord, step + 1)?; + if prev == next { + return Some(val1); } + *coord.get_mut(step)? = next; + let val2 = self.interpolate(input, in_prev, in_next, coord, step + 1)?; + + Some( + val1.iter() + .zip(val2.iter()) + .map(|(a, b)| interpolate(x, prev as f32, next as f32, *a, *b)) + .collect(), + ) + } + + /// PdfCraft patch (31): the samples of grid point `coord`, or `None` where it is off the + /// grid or the stream ended before it (where the hash map had no entry). + fn entry(&self, coord: &[u32]) -> Option<&[u32]> { + let mut index = 0usize; + for ((c, size), stride) in coord.iter().zip(&self.sizes).zip(&self.strides) { + if c >= size { + return None; + } + index = index.checked_add((*c as usize).checked_mul(*stride)?)?; + } + let n = self.range.len(); + let start = index.checked_mul(n)?; + self.samples.get(start..start.checked_add(n)?) } } @@ -257,71 +218,3 @@ impl Interpolator { /// values, 17^5 points with 4 outputs 5.7 million values. const MAX_SAMPLE_VALUES: usize = 1 << 23; const MAX_SAMPLE_ENTRIES: usize = 1 << 21; - -fn build_table(data: &[u32], sizes: &[u32], n: usize) -> Option> { - let mut key = Key::new(sizes); - let mut table = HashMap::new(); - - let mut first = true; - - for b in data.chunks_exact(n) { - if !first { - key.increment(); - } - - table.insert(key.clone(), b.to_smallvec()); - - first = false; - } - - Some(table) -} - -/// A sampled function consists of a (possibly) multi-dimensional table that we can index -/// into. We do this by representing the entries as a flat list of vectors, where each -/// element in the vector represents the value of the key in that specific dimension. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct Key { - sizes: SmallVec<[u32; 4]>, - parts: SmallVec<[u32; 4]>, -} - -impl Key { - fn new(sizes: &[u32]) -> Self { - let parts = smallvec![0; sizes.len()]; - - Self { - sizes: sizes.to_smallvec(), - parts, - } - } - - fn from_raw(sizes: &[u32], parts: &[u32]) -> Self { - Self { - sizes: sizes.to_smallvec(), - parts: parts.to_smallvec(), - } - } - - fn increment(&mut self) -> Option<()> { - self.increment_index(0) - } - - fn increment_index(&mut self, index: usize) -> Option<()> { - let size = *self.sizes.get(index).or_else(|| { - error!("overflowed key in sampled function"); - - None - })?; - let val = self.parts.get_mut(index)?; - - if *val >= (size - 1) { - *val = 0; - self.increment_index(index + 1)?; - } else { - *val += 1; - } - - Some(()) - } -} diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index a0c6308e..70bb05c7 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -120,6 +120,14 @@ pub struct InterpreterSettings { /// costs a decode and a resample each for pixels nobody can see. Never set it for a final /// render (tiles, whole pages, print, export). 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 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 + /// and uncoloured patterns), whether or not `plate` is set. + pub ink_sink: Option, } impl Default for InterpreterSettings { @@ -142,6 +150,8 @@ impl Default for InterpreterSettings { ocg_overrides: Arc::new(Vec::new()), cancelled: None, preview: false, + plate: None, + ink_sink: None, } } } @@ -175,6 +185,9 @@ pub fn interpret_page<'a>( if context.settings.render_annotations && let Some(annot_arr) = page.raw().get::>(ANNOTS) { + // PdfCraft patch (29): bracket the annotations so devices can tell their paints from + // the document's own (the reading-colours setting recolours the document, not comments). + device.begin_annotations(); for annot in annot_arr.iter::>() { let flags = annot.get::(F).unwrap_or(0); @@ -275,6 +288,7 @@ pub fn interpret_page<'a>( draw_highlight_without_appearance(&annot, context, device); } } + device.end_annotations(); } } diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 838ea099..6c75c464 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -1,6 +1,8 @@ -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, InkUse, Plate}; use crate::context::{Context, path_as_rect}; use crate::device::Device; +use crate::interpret::state::PaintData; +use crate::pattern::Pattern; use crate::util::{BezPathExt, Float32Ext}; use crate::{FillRule, Paint, PathDrawMode, StrokeProps}; use kurbo::{BezPath, Cap, Join, PathEl}; @@ -153,6 +155,23 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> context.get().non_stroke_data() }; + // PdfCraft patch (35): report spot inks, and draw separations. + if let Some(sink) = &context.settings.ink_sink { + data.color_space.report_spots(sink); + if let Some(Pattern::Shading(s)) = &data.pattern { + s.shading.color_space.report_spots(sink); + } + } + if let Some(plate) = &context.settings.plate { + let state = &context.get().graphics_state; + let overprint = if is_stroke { + state.overprint_stroke + } else { + state.overprint_fill + }; + return plate_paint(context, data, plate, overprint, state.overprint_mode); + } + if data.color_space.is_pattern() || data.pattern.is_some() { if let Some(mut pattern) = data.pattern { if let Some(tf) = &data.transfer_function { @@ -176,3 +195,39 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> } } } + +/// PdfCraft patch (35): the paint `data` as drawn on `plate`: a colour becomes its film, a +/// shading is converted through the plate, and a paint that leaves the plate as it is (an +/// overprinting paint that doesn't specify the plate's ink, or the `None` colorant) becomes +/// transparent. Transfer functions are left out: they act on device colorants, which the +/// film doesn't model. +fn plate_paint<'a>( + context: &Context<'a>, + data: PaintData<'a>, + plate: &Plate, + overprint: bool, + overprint_mode: u8, +) -> Paint<'a> { + let untouched = || Paint::Color(Color::film(0.0, 0.0)); + if !(data.color_space.is_pattern() || data.pattern.is_some()) { + let color = Color::new(data.color_space, data.color, data.alpha); + return Paint::Color(color.for_plate(plate, overprint, overprint_mode)); + } + let Some(mut pattern) = data.pattern else { + // Pattern was likely invalid, use transparent paint. + return untouched(); + }; + match &mut pattern { + Pattern::Shading(s) => { + match s.shading.color_space.ink_use(&plate.ink) { + InkUse::Nothing => return untouched(), + InkUse::Unspecified if plate.overprint && overprint => return untouched(), + InkUse::Paints | InkUse::Unspecified => {} + } + std::sync::Arc::make_mut(&mut s.shading).for_plate(plate); + } + Pattern::Tiling(t) => t.for_plate(plate, overprint, overprint_mode), + } + pattern.pre_concat_transform(context.root_transform()); + Paint::Pattern(Box::new(pattern)) +} diff --git a/vendor/hayro-interpret/src/interpret/state.rs b/vendor/hayro-interpret/src/interpret/state.rs index 95b773fb..de8f24e2 100644 --- a/vendor/hayro-interpret/src/interpret/state.rs +++ b/vendor/hayro-interpret/src/interpret/state.rs @@ -269,6 +269,12 @@ pub(crate) struct GraphicsState<'a> { pub(crate) soft_mask: Option>, pub(crate) transfer_function: Option, pub(crate) blend_mode: BlendMode, + + // PdfCraft patch (35): the overprint parameters (ISO 32000-2 §8.6.7, `OP`, `op` and `OPM` + // in a graphics state parameter dictionary). They only matter when drawing separations. + pub(crate) overprint_stroke: bool, + pub(crate) overprint_fill: bool, + pub(crate) overprint_mode: u8, } impl Default for GraphicsState<'_> { @@ -286,6 +292,9 @@ impl Default for GraphicsState<'_> { soft_mask: None, transfer_function: None, blend_mode: BlendMode::default(), + overprint_stroke: false, + overprint_fill: false, + overprint_mode: 0, } } } @@ -408,6 +417,21 @@ pub(crate) fn handle_gs_single<'a>( .map(|n| if n == 0.0 { 0.01 } else { n }) .collect(); } + // PdfCraft patch (35): `OP` sets stroking overprint, and non-stroking overprint too when + // the dictionary has no `op` of its own; `OPM` is 0 or 1 (§8.6.7). + "OP" => { + let overprint = dict.get::(key)?; + let state = &mut context.get_mut().graphics_state; + state.overprint_stroke = overprint; + if !dict.contains_key("op") { + state.overprint_fill = overprint; + } + } + "op" => context.get_mut().graphics_state.overprint_fill = dict.get::(key)?, + "OPM" => { + let mode = dict.get::(key)?; + context.get_mut().graphics_state.overprint_mode = u8::from(mode == 1); + } "Type" => {} _ => {} } diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 3d6d2229..b916a381 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -1,7 +1,7 @@ //! PDF patterns. use crate::cache::Cache; -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, Plate}; use crate::context::{Context, InterpreterCache}; use crate::device::Device; use crate::font::Glyph; @@ -147,6 +147,30 @@ pub struct TilingPattern<'a> { nesting_depth: u32, } +impl TilingPattern<'_> { + /// PdfCraft patch (35): an uncoloured pattern draws its cell in the colour it is used + /// with, so on a plate it draws that colour's film. A coloured pattern's cell is separated + /// as it is drawn. + pub(crate) fn for_plate(&mut self, plate: &Plate, overprint: bool, overprint_mode: u8) { + if !self.is_color { + self.stroke_paint = self + .stroke_paint + .for_plate(plate, overprint, overprint_mode); + self.non_stroking_paint = + self.non_stroking_paint + .for_plate(plate, overprint, overprint_mode); + } + let simulation = plate.simulation.as_ref().map(|s| s.digest()); + self.cache_key = hash128(&( + self.cache_key, + &plate.ink, + plate.overprint, + overprint, + simulation, + )); + } +} + impl Debug for TilingPattern<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str("TilingPattern") diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 98fbb583..0c4ad1e7 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -139,6 +139,16 @@ pub struct Shading { } impl Shading { + /// PdfCraft patch (35): the shading drawn as `plate`'s plate, through its simulation + /// (patch 36). + pub(crate) fn for_plate(&mut self, plate: &crate::color::Plate) { + self.color_space = self + .color_space + .plate(plate.ink.clone(), plate.simulation.clone()); + let simulation = plate.simulation.as_ref().map(|s| s.digest()); + self.cache_key = crate::util::hash128(&(self.cache_key, &plate.ink, simulation)); + } + pub(crate) fn new(dict: &Dict<'_>, stream: Option<&Stream<'_>>, cache: &Cache) -> Option { let cache_key = dict.cache_key(); @@ -731,6 +741,10 @@ where { const GRID_SIZE: usize = 20; let mut grid = vec![vec![Point::ZERO; GRID_SIZE]; GRID_SIZE]; + // PdfCraft patch (33): each grid point's colours, interpolated once. Every cell below + // interpolated its four corners itself, so an inner point was interpolated four times + // (1,444 interpolations a patch instead of 400) for the same values. + let mut colors = vec![vec![ColorComponents::new(); GRID_SIZE]; GRID_SIZE]; // Create grid by mapping unit square coordinates. for i in 0..GRID_SIZE { @@ -741,6 +755,7 @@ where // Map unit square coordinate to patch coordinate. let unit_point = Point::new(u, v); grid[i][j] = map_coordinate(unit_point); + colors[i][j] = interpolate(unit_point); } } @@ -751,32 +766,26 @@ where let p01 = grid[i][j + 1]; let p11 = grid[i + 1][j + 1]; - // Calculate unit square coordinates for color interpolation. - let u0 = i as f64 / (GRID_SIZE - 1) as f64; - let u1 = (i + 1) as f64 / (GRID_SIZE - 1) as f64; - let v0 = j as f64 / (GRID_SIZE - 1) as f64; - let v1 = (j + 1) as f64 / (GRID_SIZE - 1) as f64; - - // Create triangle vertices with interpolated colors. + // Create triangle vertices with their grid points' colors. let v00 = TriangleVertex { flag: 0, point: p00, - colors: interpolate(Point::new(u0, v0)), + colors: colors[i][j].clone(), }; let v10 = TriangleVertex { flag: 0, point: p10, - colors: interpolate(Point::new(u1, v0)), + colors: colors[i + 1][j].clone(), }; let v01 = TriangleVertex { flag: 0, point: p01, - colors: interpolate(Point::new(u0, v1)), + colors: colors[i][j + 1].clone(), }; let v11 = TriangleVertex { flag: 0, point: p11, - colors: interpolate(Point::new(u1, v1)), + colors: colors[i + 1][j + 1].clone(), }; let inflate_point = |p: Point, mid: Point| -> Point { @@ -795,16 +804,9 @@ where triangle }; - buffer.push(inflate(Triangle::new( - v00.clone(), - v10.clone(), - v01.clone(), - ))); - buffer.push(inflate(Triangle::new( - v10.clone(), - v11.clone(), - v01.clone(), - ))); + // PdfCraft patch (33): the second triangle takes the vertices the first copied. + buffer.push(inflate(Triangle::new(v00, v10.clone(), v01.clone()))); + buffer.push(inflate(Triangle::new(v10, v11, v01))); } } } diff --git a/vendor/hayro-interpret/src/soft_mask.rs b/vendor/hayro-interpret/src/soft_mask.rs index 92cab551..657d6bbf 100644 --- a/vendor/hayro-interpret/src/soft_mask.rs +++ b/vendor/hayro-interpret/src/soft_mask.rs @@ -169,7 +169,13 @@ impl<'a> SoftMask<'a> { transfer_function, bbox: context.bbox(), interpreter_cache: context.interpreter_cache.clone(), - settings: context.settings.clone(), + // PdfCraft patch (35): a mask's luminosity comes from its group's colours, never + // from a plate, and the inks it uses are never printed. + settings: InterpreterSettings { + plate: None, + ink_sink: None, + ..context.settings.clone() + }, xref: context.xref, background, parent_resources, diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 767a54f3..83a0aadd 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,5 +1,5 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; -use crate::color::{ColorComponents, ColorSpace, ToRgb}; +use crate::color::{ColorComponents, ColorSpace, InkUse, Plate, ToRgb}; use crate::context::Context; use crate::device::Device; use crate::function::{Function, interpolate}; @@ -60,6 +60,9 @@ pub(crate) struct FormXObject<'a> { pub(crate) matrix: Affine, pub(crate) bbox: [f32; 4], is_transparency_group: bool, + // PdfCraft patch: the group's /I and /K (ISO 32000-2 §11.6.6, both false by default). + is_isolated: bool, + is_knockout: bool, pub(crate) dict: Dict<'a>, resources: Option>, } @@ -77,12 +80,17 @@ impl<'a> FormXObject<'a> { .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), ); let bbox = dict.get::<[f32; 4]>(BBOX)?; - let is_transparency_group = dict.get::>(GROUP).is_some(); + let group = dict.get::>(GROUP); + let is_transparency_group = group.is_some(); + let is_isolated = group.as_ref().and_then(|g| g.get::(I)).unwrap_or(false); + let is_knockout = group.as_ref().and_then(|g| g.get::(K)).unwrap_or(false); Some(Self { decoded, matrix, is_transparency_group, + is_isolated, + is_knockout, bbox, dict: dict.clone(), resources, @@ -134,7 +142,57 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.pre_concat_affine(x_object.matrix); context.push_root_transform(); - if x_object.is_transparency_group { + // PdfCraft patch: a transparency group that is neither isolated nor knockout composites + // its elements with the backdrop already on the page (§11.4.7), so when it is entered at + // full opacity with no soft mask and Normal blending there is nothing for a layer of its + // own to add, and drawing straight onto the page is the exact result. A layer here was + // wrong, not just slower: a blend mode inside the group (the `/BM /Multiply` of a highlight + // appearance written by other editors) saw the layer's transparent backdrop instead of the + // text, and the layer then covered the page as an opaque block (#1968). An isolated group + // starts from a blank backdrop by definition, and a knockout group or one entered at partial + // opacity, under a soft mask or with a blend mode keeps its layer, where the group's + // elements still composite with a blank backdrop (an approximation of §11.4.7 for them). + let neutral = !x_object.is_isolated && !x_object.is_knockout && { + let state = &context.get().graphics_state; + state.non_stroke_alpha >= 1.0 + && state.stroke_alpha >= 1.0 + && state.soft_mask.is_none() + && state.blend_mode == BlendMode::Normal + }; + // Knockout patch (pdfcraft #2974): a group drawn as an element of a knockout group replaces + // what that group drew under its shape, the union of its own elements' shapes (pdfcraft #2971). + // The shape is cleared by drawing the group's content once in the device's erase mode; the + // group then composites over the cleared area in a layer of its own (also when it would + // otherwise be drawn straight onto its parent, where its elements would knock each other out). + let knockout_element = x_object.is_transparency_group && device.knockout_erase_wanted(); + if knockout_element { + context.save_state(); + device.begin_knockout_erase(); + device.push_clip_path(&ClipPath { + path: context.get().ctm + * Rect::new( + x_object.bbox[0] as f64, + x_object.bbox[1] as f64, + x_object.bbox[2] as f64, + x_object.bbox[3] as f64, + ) + .to_path(0.1), + fill: FillRule::NonZero, + }); + interpret( + TypedIter::new(x_object.decoded.as_ref()), + &Resources::from_scope(x_object.resources.clone(), resources.clone()), + context, + device, + ); + device.pop_clip_path(); + device.end_knockout_erase(); + context.restore_state(device); + context.path_mut().truncate(0); + } + let own_layer = x_object.is_transparency_group && (!neutral || knockout_element); + + if own_layer { device.push_transparency_group( context.get().graphics_state.non_stroke_alpha, std::mem::take(&mut context.get_mut().graphics_state.soft_mask), @@ -143,6 +201,10 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.get_mut().graphics_state.non_stroke_alpha = 1.0; context.get_mut().graphics_state.stroke_alpha = 1.0; + // Knockout patch (pdfcraft #2974): its elements knock each other out (pdfcraft #2971). + if x_object.is_knockout { + device.set_knockout(); + } } device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); @@ -169,7 +231,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>( device.pop_clip_path(); - if x_object.is_transparency_group { + if own_layer { device.pop_transparency_group(); } @@ -200,6 +262,37 @@ pub(crate) fn draw_image_xobject<'a, 'b>( return; } + // PdfCraft patch (35): report the image's spot inks, and draw it on a plate. An image + // without a colour space takes one from its data (JPEG, JPEG 2000): gray, RGB or CMYK, + // all process colour. Images paint with the non-stroking overprint parameter. + let raster_cs = if x_object.is_mask { + None + } else { + x_object.color_space.as_ref() + }; + if let (Some(sink), Some(cs)) = (&context.settings.ink_sink, raster_cs) { + cs.report_spots(sink); + } + let mut on_plate = None; + if let Some(plate) = &context.settings.plate + && !x_object.is_mask + { + let ink_use = match raster_cs { + Some(cs) => cs.ink_use(&plate.ink), + None if plate.ink.process_index().is_some() => InkUse::Paints, + None => InkUse::Unspecified, + }; + let overprint = plate.overprint && context.get().graphics_state.overprint_fill; + if ink_use == InkUse::Nothing || (ink_use == InkUse::Unspecified && overprint) { + if has_oc { + context.ocg_state.end_marked_content(); + } + return; + } + on_plate = Some(x_object.for_plate(plate)); + } + let x_object = on_plate.as_ref().unwrap_or(x_object); + let width = x_object.width as f64; let height = x_object.height as f64; @@ -224,6 +317,17 @@ pub(crate) fn draw_image_xobject<'a, 'b>( soft_mask = None; } + // Knockout patch (pdfcraft #2974): an image without a mask covers its whole placement + // rectangle, so in a knockout group it replaces what the group drew there (pdfcraft #2971). A + // stencil's or a masked image's shape is its mask; those composite as before (exact where the + // mask is opaque or transparent, which is what masks mostly are). Only an image the device + // draws clears: one that doesn't decode (over `MAX_IMAGE_PIXELS`, damaged data) or that it + // skips (a preview's tiny image) replaces nothing. The device decides, at its own draw size + // (review on #2974: a full-size decode here cost a second decode of every downscaled image). + if !x_object.is_mask && !has_alpha && device.in_knockout_group() { + device.knockout_image(&RasterImage(x_object.clone()), transform); + } + device.push_transparency_group( context.get().graphics_state.non_stroke_alpha, std::mem::take(&mut soft_mask), @@ -281,9 +385,22 @@ pub(crate) struct ImageXObject<'a> { stream: Stream<'a>, transfer_function: Option, warning_sink: WarningSinkFn, + /// PdfCraft patch (35): the plate the image is drawn on, if any. + plate: Option, } impl<'a> ImageXObject<'a> { + /// PdfCraft patch (35): the image drawn on `plate`. Not cached (the cache holds colour + /// decodes), and without a transfer function (see `get_paint`). + fn for_plate(&self, plate: &Plate) -> Self { + Self { + plate: Some(plate.clone()), + decoded_cache: None, + transfer_function: None, + ..self.clone() + } + } + pub(crate) fn new( stream: &Stream<'a>, resolve_cs: impl FnOnce(&Name<'_>) -> Option, @@ -344,6 +461,7 @@ impl<'a> ImageXObject<'a> { stream: stream.clone(), is_mask, is_stencil_mask, + plate: None, }) } @@ -407,11 +525,28 @@ impl<'a> ImageXObject<'a> { is_mask: self.is_mask, is_stencil_mask: self.is_stencil_mask, interpolate: self.interpolate, - target: target_dimension, + // PdfCraft patch (32): see `decode_reads_target`. + target: if self.decode_reads_target() { + target_dimension + } else { + None + }, preview: cache.preview, }) } + /// PdfCraft patch (32): whether decoding this image depends on the size it is drawn at. + /// Only the JPX filter decodes at a reduced resolution for a target size, in the image + /// itself or in the soft mask or mask decoded with it; every other filter decodes the whole + /// image whatever the target. Keyed by the target, a scan was decoded again at every zoom + /// step and kept once per zoom level; without it, one decode serves them all. + fn decode_reads_target(&self) -> bool { + let dict = self.stream.dict(); + uses_jpx(&self.stream) + || dict.get::>(SMASK).is_some_and(|s| uses_jpx(&s)) + || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s)) + } + pub(crate) fn width(&self) -> u32 { self.width } @@ -440,6 +575,20 @@ fn decoded_mask_size(mask: &DecodedMask) -> usize { mask.luma.width as usize * mask.luma.height as usize } +/// PdfCraft patch (32): whether `stream` names the JPX filter, as a name or in an array, under +/// `/Filter` or its inline abbreviation `/F`. Errs towards `true`: a name the decoder would +/// ignore still counts, which only keeps the target in the key as before. +fn uses_jpx(stream: &Stream<'_>) -> bool { + let dict = stream.dict(); + let is_jpx = |name: &Name<'_>| name.deref() == JPX_DECODE; + [F, FILTER].into_iter().any(|key| { + dict.get::>(key).is_some_and(|name| is_jpx(&name)) + || dict + .get::>(key) + .is_some_and(|filters| filters.iter::>().any(|name| is_jpx(&name))) + }) +} + /// PdfCraft patch: pixel bytes held by a decoded raster image and its mask. fn decoded_raster_size(raster: &DecodedRaster) -> usize { let image = match &raster.image { @@ -536,6 +685,11 @@ fn decode_context<'a>( }) }) .unwrap_or(ColorSpace::device_gray()); + // PdfCraft patch (35): on a plate, every sample converts to the ink's film. + let color_space = match &obj.plate { + Some(plate) => color_space.plate(plate.ink.clone(), plate.simulation.clone()), + None => color_space, + }; let fallback_bpc = if obj.is_stencil_mask { 1 } else { 8 }; @@ -667,23 +821,38 @@ fn decode_raster( ctx.bits_per_component, )?; - let mut f32_data = apply_decode_array( + // PdfCraft patch (28): an image with few possible pixel values converts each of them once. + let mut rgb_data = if let Some(rgb) = rgb_via_lookup( &components, - &ctx.color_space, - ctx.bits_per_component, - &ctx.decode_arr, - )?; - - fix_image_length(&mut f32_data, ctx.width, &mut height, 0.0, &ctx.color_space)?; - - let mut rgb_data = get_rgb_data( - &f32_data, ctx.width, height, ctx.scale_factors, &ctx.color_space, + ctx.bits_per_component, + &ctx.decode_arr, obj.interpolate, - ); + ) { + height = rgb.height; + Some(rgb) + } else { + let mut f32_data = apply_decode_array( + &components, + &ctx.color_space, + ctx.bits_per_component, + &ctx.decode_arr, + )?; + + fix_image_length(&mut f32_data, ctx.width, &mut height, 0.0, &ctx.color_space)?; + + get_rgb_data( + &f32_data, + ctx.width, + height, + ctx.scale_factors, + &ctx.color_space, + obj.interpolate, + ) + }; if let Some(transfer_function) = &obj.transfer_function && let Some(rgb_data) = &mut rgb_data @@ -1034,27 +1203,136 @@ fn apply_decode_array( bits_per_component: u8, decode: &[(f32, f32)], ) -> Option> { - let interpolate = |n: f32, d_min: f32, d_max: f32| { - interpolate( - n, - 0.0, - 2.0_f32.powi(bits_per_component as i32) - 1.0, - d_min, - d_max, - ) - }; - let mut decoded_arr = vec![]; for pixel in components.chunks(color_space.num_components() as usize) { - for (component, (d_min, d_max)) in pixel.iter().zip(decode) { - decoded_arr.push(interpolate(*component as f32, *d_min, *d_max)); + for (component, range) in pixel.iter().zip(decode) { + decoded_arr.push(decode_sample(*component, bits_per_component, *range)); } } Some(decoded_arr) } +/// One sample mapped through its `/Decode` range (ISO 32000-2 §8.9.5.2). PdfCraft patch (28): +/// shared by [`apply_decode_array`] and [`rgb_via_lookup`], so both decode alike. +fn decode_sample(sample: u16, bits_per_component: u8, (d_min, d_max): (f32, f32)) -> f32 { + interpolate( + sample as f32, + 0.0, + 2.0_f32.powi(bits_per_component as i32) - 1.0, + d_min, + d_max, + ) +} + +/// PdfCraft patch (28): the most bits a pixel's samples may hold together for +/// [`rgb_via_lookup`] (a table of 65,536 colours). +const MAX_LOOKUP_BITS: usize = 16; + +/// PdfCraft patch (28): the RGB of an image whose pixels hold at most [`MAX_LOOKUP_BITS`] bits +/// of samples, converted once per possible pixel value rather than once per pixel, at the +/// height `fix_image_length` gives. `None` when the table would not pay off (the image has no +/// more pixels than it has entries) or does not apply; the caller then converts every pixel. +/// +/// The colour of a pixel depends only on its own samples, so the result is the per-pixel one, +/// except where a tint transform gives the wrong number of components. The per-pixel batch then +/// shifts the colours after that pixel. The table converts each entry on its own +/// ([`convert_each`]), so a wrong-sized entry changes only itself, and a value the image never +/// uses cannot change the colour of one it does. +/// A Separation or DeviceN tint transform is a PDF function, often a PostScript calculator +/// program: run once per pixel, an 8-bit spot-colour background of 3304 × 4105 took four +/// seconds and 430 MB to draw; looked up from 256 entries it takes milliseconds. +#[allow(clippy::too_many_arguments)] +fn rgb_via_lookup( + components: &[u16], + width: u32, + height: u32, + scale_factors: (f32, f32), + cs: &ColorSpace, + bits_per_component: u8, + decode: &[(f32, f32)], + interpolate: bool, +) -> Option { + let n = cs.num_components() as usize; + let bpc = usize::from(bits_per_component); + let bits = bpc.checked_mul(n)?; + // `apply_decode_array` decodes only the samples `/Decode` has a range for: leave a short + // array to it, and every sample size it doesn't read. + if n == 0 || bits == 0 || bits > MAX_LOOKUP_BITS || bpc > 16 || decode.len() < n { + return None; + } + // The length rules of `fix_image_length`: extra samples are dropped; missing ones shorten + // the image to the rows begun, the last row padded with decoded zeros. + let row_len = usize::try_from(width).ok()?.checked_mul(n)?; + let wanted = row_len.checked_mul(usize::try_from(height).ok()?)?; + let (height, used) = if wanted <= components.len() { + (height, wanted) + } else { + (u32::try_from(components.len().div_ceil(row_len)).ok()?, components.len()) + }; + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + let total = pixels.checked_mul(n)?; + let entries = 1usize << bits; + if pixels <= entries { + return None; + } + + // Every possible pixel value, its samples in order, decoded as `apply_decode_array` would. + let mask = (1usize << bpc) - 1; + let mut table_input = Vec::with_capacity(entries * n); + for code in 0..entries { + for (k, range) in decode.iter().take(n).enumerate() { + let sample = ((code >> (bpc * (n - 1 - k))) & mask) as u16; + table_input.push(decode_sample(sample, bits_per_component, *range)); + } + } + let mut table = vec![0; entries * 3]; + convert_each(cs, &table_input, n, &mut table); + + let mut data = vec![0; pixels.checked_mul(3)?]; + let whole = used / n; + let samples = components.get(..whole * n)?; + for (pixel, rgb) in samples.chunks_exact(n).zip(data.chunks_exact_mut(3)) { + let code = pixel.iter().fold(0usize, |code, s| (code << bpc) | (usize::from(*s) & mask)); + if let Some(colour) = table.get(code * 3..code * 3 + 3) { + rgb.copy_from_slice(colour); + } + } + // The pixels past the samples present (at most a row): converted as before, padding and all. + if whole < pixels { + let tail: Vec = (whole * n..total) + .map(|i| match (components.get(i), decode.get(i % n)) { + (Some(s), Some(range)) if i < used => decode_sample(*s, bits_per_component, *range), + _ => 0.0, + }) + .collect(); + let _ = cs.convert_f32(&tail, data.get_mut(whole * 3..)?, false); + } + + Some(RgbData { + data, + width, + height, + interpolate, + scale_factors, + }) +} + +/// PdfCraft patch (28): `input`, `n` decoded samples a pixel, converted into `output` (RGB) one +/// pixel at a time. A Separation or DeviceN tint transform may give more or fewer components +/// than its alternate space takes (a Type 4 program's count is only warned about): converted as +/// one batch, such a result shifts every pixel after it; converted alone, it affects only its +/// own. A failed conversion leaves black, as in `get_rgb_data`. +fn convert_each(cs: &ColorSpace, input: &[f32], n: usize, output: &mut [u8]) { + if n == 0 { + return; + } + for (pixel, rgb) in input.chunks_exact(n).zip(output.chunks_exact_mut(3)) { + let _ = cs.convert_f32(pixel, rgb, false); + } +} + /// PdfCraft patch: whether an image or mask of `width` × `height` may be decoded (at most /// 2^28 pixels, the renderer's `MAX_IMAGE_PIXELS`). pub fn image_size_ok(width: u32, height: u32) -> bool { diff --git a/vendor/hayro-jbig2/SOURCE.json b/vendor/hayro-jbig2/SOURCE.json index 50ec8937..bf51f3bb 100644 --- a/vendor/hayro-jbig2/SOURCE.json +++ b/vendor/hayro-jbig2/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro-jbig2", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro-jbig2", "files": [ { diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index 721bac61..6419aee4 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro-syntax", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro-syntax", "files": [ { @@ -132,9 +132,9 @@ }, { "path": "src/filter/ascii_85.rs", - "source_sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24", - "source_git_blob": "5a18cf8352d7d5a1770b4ea3068254187ff4b4f9", - "sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24" + "source_sha256": "550cd5b3ceb7ee9342959ac9cc80c35b176aed3c7f061789e379beb8b5873f48", + "source_git_blob": "943bfeb742ac8cdc8ba64fcc538a044cb812577e", + "sha256": "550cd5b3ceb7ee9342959ac9cc80c35b176aed3c7f061789e379beb8b5873f48" }, { "path": "src/filter/ascii_hex.rs", @@ -144,9 +144,9 @@ }, { "path": "src/filter/ccitt.rs", - "source_sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf", - "source_git_blob": "98924f2dbdd942bc6ee64f66749b8d2496d8b45e", - "sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf" + "source_sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8", + "source_git_blob": "685d680d2fbadce608a9bd3ca925c2ccc6c5d641", + "sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8" }, { "path": "src/filter/dct.rs", @@ -162,21 +162,21 @@ }, { "path": "src/filter/jpx.rs", - "source_sha256": "a9cab59f286bf3d3c99aa008f8fdede5028117c0f36018f3c02e52ed097c299a", - "source_git_blob": "6a788591e3338c55eccd616e21aa6e683f8d3351", - "sha256": "a9cab59f286bf3d3c99aa008f8fdede5028117c0f36018f3c02e52ed097c299a" + "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", + "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", + "sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82" }, { "path": "src/filter/lzw_flate.rs", - "source_sha256": "e2d2196bc47c00474dc8f61d281ba327b88aa0d5f9e5475d29bf63a0572522e3", - "source_git_blob": "f9da1590b27b3e582ddac2fdfe9c1ef80ea72aff", - "sha256": "e1a6125b27423c91d79127ce6f8bfec507bd4d6ab0f959b2740a8a6e392d3e49" + "source_sha256": "db51a57effe90ad9db2d314e16081ce90e3ef99e5a5f46fcc4bdbdacfd13a528", + "source_git_blob": "f1ab1aae73e4c84ec0993a6eb96594018ca7cc91", + "sha256": "00d478a55b0c123d903768186592d138d563543abf46e5c786c67b9fffd69637" }, { "path": "src/filter/mod.rs", - "source_sha256": "5e6d62da5b1fd023f85f79bc8ade928b508214e13668f9fc99f3a08bc4d48a15", - "source_git_blob": "22bebf6d3287f1dc893c67b76a139108b28a8188", - "sha256": "8cc6c2572519046bd27adfc597630045e453829a954eaf1747fee7a447e5c5cb" + "source_sha256": "0af7145ab16b15b7a60c4b57efecb9bd5fd0cfdc9bb5f9388abb695386e63e49", + "source_git_blob": "06bda29487d62c25dc39c1ee109091cb17e3867f", + "sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9" }, { "path": "src/filter/run_length.rs", @@ -276,9 +276,9 @@ }, { "path": "src/object/stream.rs", - "source_sha256": "151721c7bfe558a6942aba323d3185cf6db5bf726bd905326881c70ef480240f", - "source_git_blob": "833610817b4738304a7376cd43ea079cb352bd8c", - "sha256": "e83d7ecb01a038d5a0ae589d77e877f9521097010e5f88db8f8a333fd161aff1" + "source_sha256": "f5229eb2f52c9918830027c93112e042a9e97d34b5fec72f97dcc7de8c5410ac", + "source_git_blob": "a80f0730cba317ed3771cee61d6a7ab18de296d7", + "sha256": "6cd27e9952715244a35d3f2ea610f401ae75fe60cf9740b69257a7ca704cce0b" }, { "path": "src/object/string.rs", @@ -294,9 +294,9 @@ }, { "path": "src/page.rs", - "source_sha256": "19d0e7604a3a692956e523128022d07944253b308793598795e09595cb04e79e", - "source_git_blob": "7d63931a324f5faf90bb37e0a90670643567dace", - "sha256": "681681016eda0d1cdbec10d2cb0305c5f439724ec5d0b3be218a18e99b5e72b8" + "source_sha256": "17be32b2eaa03a3367f1f20040556f7ee5b207588cf44a1af3e5253386fb46f9", + "source_git_blob": "d7f0ec953722a938508f1f31f0adaecc0a181d20", + "sha256": "8993224b8f403c95c32803113139fe4c8fc25338571466b37bad0874b21f8145" }, { "path": "src/pdf.rs", diff --git a/vendor/hayro-syntax/src/filter/ascii_85.rs b/vendor/hayro-syntax/src/filter/ascii_85.rs index 5a18cf83..943bfeb7 100644 --- a/vendor/hayro-syntax/src/filter/ascii_85.rs +++ b/vendor/hayro-syntax/src/filter/ascii_85.rs @@ -4,7 +4,9 @@ use crate::reader::Reader; use crate::trivia::is_white_space_character; use alloc::vec::Vec; -pub(crate) fn decode(data: &[u8]) -> Option> { +/// PdfCraft patch: `limit` is the most bytes produced (see `filter::decode_limit`); each `z` +/// expands one input byte to four. +pub(crate) fn decode(data: &[u8], limit: usize) -> Option> { const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; let mut reader = Reader::new(data); @@ -50,15 +52,21 @@ pub(crate) fn decode(data: &[u8]) -> Option> { .checked_add(digits[3].checked_mul(POW_85[3])?)? .checked_add(digits[4])?; - decoded.extend_from_slice(&value.to_be_bytes()[..output_len]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend_from_slice(&value.to_be_bytes()[..output_len.min(left)]); group.clear(); Some(()) }; - let mut decoded = Vec::with_capacity(data.len() * 4 / 5); + let mut decoded = Vec::with_capacity((data.len() / 5 * 4).min(limit)); let mut group = Vec::with_capacity(5); loop { + // PdfCraft patch: output cut at the limit is reported, never dropped silently. + if decoded.len() >= limit { + warn!("ASCII85 stream stopped at its decode limit of {limit} bytes"); + return Some(decoded); + } let Some(b) = read_byte() else { // Be lenient and accept what we have (see PDFBOX-5910). flush_group(&mut group, &mut decoded)?; @@ -76,7 +84,8 @@ pub(crate) fn decode(data: &[u8]) -> Option> { } b'z' => { flush_group(&mut group, &mut decoded)?; - decoded.extend_from_slice(&[0, 0, 0, 0]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend(core::iter::repeat_n(0, left.min(4))); } b'~' => { // Technically requires a '>', but there is a PDF where it isn't diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 98924f2d..685d680d 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -15,7 +15,15 @@ pub(crate) fn decode( ) -> Option> { let k = params.get::(K).unwrap_or(0); - let rows = params.get::(ROWS).unwrap_or(image_params.height); + // PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and + // the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank. + // PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a + // plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black + // (#3308). The decoder still stops where the data ends. + let rows = params + .get::(ROWS) + .filter(|&rows| rows != 0) + .map_or(image_params.height, |rows| rows.max(image_params.height)); let end_of_block = params.get::(END_OF_BLOCK).unwrap_or(true); let settings = DecodeSettings { diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 6a788591..74efd8b3 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option image, + Err(_) => { + repaired = without_refused_enumerated_colour(data)?; + hayro_jpeg2000::Image::new(&repaired, &settings).ok()? + } + }; let width = image.width(); let height = image.height(); @@ -87,6 +98,82 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option Option> { + let header = boxes(data).find(|&(kind, _, _)| kind == *b"jp2h")?; + let (_, start, end) = header; + let inner = data.get(start..end)?; + let (_, ih_start, _) = boxes(inner).find(|&(kind, _, _)| kind == *b"ihdr")?; + // ihdr: height (4), width (4), number of components (2). + let components = u16::from_be_bytes(inner.get(ih_start + 8..ih_start + 10)?.try_into().ok()?); + let replacement: u32 = match components { + 1 => 17, + 3 => 16, + 4 => 12, + _ => return None, + }; + // Where each refused enum is, found before anything is copied: a stream the decoder refused + // for another reason isn't copied for nothing. + let mut refused = Vec::new(); + for (kind, body, body_end) in boxes(inner) { + if kind != *b"colr" { + continue; + } + // colr: METH (1), PREC (1), APPROX (1), then EnumCS (4) when METH is 1. + let colr = inner.get(body..body_end)?; + if colr.first() != Some(&1) { + continue; + } + let enumerated = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?); + if DECODED_ENUMERATED.contains(&enumerated) { + continue; + } + refused.push(start.checked_add(body)?.checked_add(3)?); + } + if refused.is_empty() { + return None; + } + let mut out = data.to_vec(); + for at in refused { + out.get_mut(at..at.checked_add(4)?)?.copy_from_slice(&replacement.to_be_bytes()); + } + Some(out) +} + +/// The boxes in `data` (ISO 15444-1 I.4): each one's type and the start and end of its contents. +/// Stops at the first box whose length doesn't fit. +fn boxes(data: &[u8]) -> impl Iterator + '_ { + let mut at = 0usize; + core::iter::from_fn(move || { + let length = u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?); + let kind: [u8; 4] = data.get(at + 4..at.checked_add(8)?)?.try_into().ok()?; + let (header, size) = match length { + // The rest of the data. + 0 => (8, data.len().checked_sub(at)?), + // An 8-byte length follows the type. + 1 => { + let large = u64::from_be_bytes(data.get(at + 8..at.checked_add(16)?)?.try_into().ok()?); + (16, usize::try_from(large).ok()?) + } + n => (8, usize::try_from(n).ok()?), + }; + let end = at.checked_add(size)?; + if size < header || end > data.len() { + return None; + } + let item = (kind, at + header, end); + at = end; + Some(item) + }) +} + fn scale( data: &[u8], bit_per_component: u8, @@ -134,7 +221,7 @@ fn scale( #[cfg(test)] mod tests { - use super::scale; + use super::{scale, without_refused_enumerated_colour}; use crate::bit_reader::{BitReader, bit_mask}; use alloc::vec::Vec; @@ -188,4 +275,87 @@ mod tests { } } } + + /// A JP2 header (`jp2h`) holding `ihdr` for `components` and the given `colr` bodies, with + /// a codestream box after it. + fn jp2(components: u16, colrs: &[&[u8]]) -> Vec { + let jp2_box = |kind: &[u8], body: &[u8]| { + let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + }; + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 1]); + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); + let mut header = jp2_box(b"ihdr", &ihdr); + for c in colrs { + header.extend(jp2_box(b"colr", c)); + } + let mut out = jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "); + out.extend(jp2_box(b"jp2h", &header)); + out.extend(jp2_box(b"jp2c", &[0xff, 0x4f])); + out + } + + fn enumerated(e: u32) -> Vec { + let mut c = alloc::vec![1, 0, 0]; + c.extend_from_slice(&e.to_be_bytes()); + c + } + + /// The enumerated colour spaces found in the copy's `colr` boxes, in order. + fn enums_in(data: &[u8]) -> Vec { + data.windows(4) + .enumerate() + .filter(|(_, w)| *w == b"colr") + .filter(|(i, _)| data.get(i + 4) == Some(&1)) + .map(|(i, _)| u32::from_be_bytes(data[i + 7..i + 11].try_into().unwrap())) + .collect() + } + + #[test] + fn refused_enumerated_colour_spaces_become_the_component_counts_default() { + // CIEJab (19) and other refused spaces, by component count. + for (components, refused, expected) in [(3, 19, 16), (3, 22, 16), (1, 15, 17), (4, 13, 12), (3, 1, 16)] { + let data = jp2(components, &[&enumerated(refused)]); + let out = without_refused_enumerated_colour(&data).unwrap(); + assert_eq!(enums_in(&out), [expected], "{refused} with {components} component(s)"); + assert_eq!(out.len(), data.len(), "only the enum changes"); + } + // Several colour specifications: each refused one, and only those. + let out = without_refused_enumerated_colour(&jp2(3, &[&enumerated(19), &enumerated(14), &enumerated(24)])).unwrap(); + assert_eq!(enums_in(&out), [16, 14, 16]); + // An 8-byte box length (LBox 1) is read too. + let mut long = jp2(3, &[&enumerated(19)]); + let at = long.windows(4).position(|w| w == b"jp2h").unwrap() - 4; + let len = u32::from_be_bytes(long[at..at + 4].try_into().unwrap()) as u64 + 8; + long.splice(at..at + 4, [0, 0, 0, 1]); + long.splice(at + 8..at + 8, (len).to_be_bytes()); + assert_eq!(enums_in(&without_refused_enumerated_colour(&long).unwrap()), [16]); + } + + #[test] + fn what_the_decoder_handles_or_cant_be_read_is_left_alone() { + for known in [12, 14, 16, 17, 18, 20, 21] { + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&enumerated(known)])), None, "{known}"); + } + // An ICC profile (METH 2), a component count with no default, no header at all. + // (Its bytes where an enum would be read say 19: only METH 1 is an enum.) + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&[2, 0, 0, 0, 0, 0, 19, 0, 0]])), None); + assert_eq!(without_refused_enumerated_colour(&jp2(2, &[&enumerated(19)])), None); + assert_eq!(without_refused_enumerated_colour(&[0xff, 0x4f, 0xff, 0x51]), None); + // Damaged lengths: none of these may panic, and none is changed. + let good = jp2(3, &[&enumerated(19)]); + for cut in 0..good.len() { + let _ = without_refused_enumerated_colour(&good[..cut]); + } + for bad in [0x0000_0004_u32, 0x7fff_ffff, 0xffff_ffff] { + let mut data = good.clone(); + let at = data.windows(4).position(|w| w == b"colr").unwrap() - 4; + data[at..at + 4].copy_from_slice(&bad.to_be_bytes()); + assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}"); + } + } } diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index e65e83bd..9642a749 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -560,23 +560,27 @@ pub(crate) mod flate { /// Copies `left` bytes from `distance` back in the output, up to the limit. Returns how /// many are still to copy. A copy whose source does not exist writes nothing. - fn copy_back(&mut self, valid: bool, distance: usize, mut left: usize) -> usize { - while left > 0 && self.output.len() < self.limit { - if valid { - let byte = self - .output - .len() - .checked_sub(distance) - .and_then(|index| self.output.get(index)) - .copied(); - if let Some(byte) = byte { - self.output.push(byte); - } + fn copy_back(&mut self, valid: bool, distance: usize, left: usize) -> usize { + let len = self.output.len(); + // Without a source the copy writes nothing, and it runs out only at the limit. + let Some(mut start) = len.checked_sub(distance).filter(|_| valid && distance > 0) else { + return if len < self.limit { 0 } else { left }; + }; + // PdfCraft patch: copy in runs of up to `distance` bytes. Each run reads only bytes + // already in the output, so the bytes are the ones a byte-at-a-time copy writes. + let copy = left.min(self.limit.saturating_sub(len)); + let mut done = 0; + while done < copy { + let end = start.saturating_add((copy - done).min(distance)); + if end > self.output.len() { + break; } - left -= 1; + self.output.extend_from_within(start..end); + done += end - start; + start = end; } - left + left - done } fn read_dynamic_tables(&mut self) -> Option<(HuffmanTable, HuffmanTable)> { @@ -1318,6 +1322,52 @@ mod tests { ); } + /// PdfCraft patch: 300 zero bytes, zlib-compressed: one literal, then copies of distance 1 that + /// are longer than their distance. + const ZEROS_300: [u8; 13] = [0x78, 0xda, 0x63, 0x60, 0x18, 0x05, 0xc4, 0x02, 0x00, 0x01, 0x2c, 0x00, 0x01]; + + #[test] + fn back_reference_longer_than_its_distance_repeats_its_source() { + let mut state = flate::FlateState::new(&ZEROS_300); + let mut out = Vec::new(); + state.decode_into(&ZEROS_300, &mut out, 1000); + + assert_eq!(out, vec![0; 300]); + } + + #[test] + fn back_reference_cut_by_the_limit_resumes_in_the_next_call() { + let mut state = flate::FlateState::new(&ZEROS_300); + let mut out = Vec::new(); + state.decode_into(&ZEROS_300, &mut out, 100); + assert_eq!(out, vec![0; 100]); + + state.decode_into(&ZEROS_300, &mut out, 1000); + assert_eq!(out, vec![0; 300]); + } + + #[test] + fn back_reference_reads_bytes_written_by_an_earlier_call() { + // "abc" repeated 2000 times, zlib-compressed: copies reach 3 bytes back, and the calls below + // stop every 7 bytes, so most copies start in an earlier call's output. + const ABC_2000: [u8; 32] = [ + 0x78, 0xda, 0xed, 0xc2, 0x41, 0x11, 0x00, 0x00, 0x0c, 0x02, 0xa0, 0xac, 0x6a, 0xff, 0x0e, 0x0b, + 0xb1, 0x2f, 0x1c, 0xe9, 0xa2, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xfe, 0x1e, 0x2f, 0x01, 0xf9, 0x59, + ]; + let expected = b"abc".repeat(2000); + let mut state = flate::FlateState::new(&ABC_2000); + let mut out = Vec::new(); + loop { + let before = out.len(); + state.decode_into(&ABC_2000, &mut out, before + 7); + if out.len() == before || out.len() >= expected.len() { + break; + } + } + + assert_eq!(out, expected); + } + #[test] fn predictor_paeth() { predictor_test( diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 90ffd76e..e3622795 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -95,7 +95,7 @@ impl Filter { Self::AsciiHexDecode => ascii_hex::decode(data) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), - Self::Ascii85Decode => ascii_85::decode(data) + Self::Ascii85Decode => ascii_85::decode(data, limit) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), Self::RunLengthDecode => run_length::decode(data, limit) diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index c975ed9f..ac911e85 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -351,14 +351,25 @@ impl FilterResult<'_> { } } +/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services +/// writes `stream \r\n`, a space before the line break, which the specification does not allow. +/// Without this the content stream was taken to start at the space, failed to inflate, and the +/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and +/// tabs are skipped, and only when a line break follows, so stream data is never swallowed. +fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> { + r.forward_while(|b| b == b' ' || b == b'\t'); + r.forward_tag(b"\n") + .or_else(|| r.forward_tag(b"\r\n")) + // Technically not allowed, but no reason to not try it. + .or_else(|| r.forward_tag(b"\r")) +} + fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { let length = dict.get::(LENGTH)?; r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let data = r.read_bytes(length as usize)?; r.skip_white_spaces(); r.forward_tag(b"endstream")?; @@ -374,11 +385,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> // A wrong `/Length` still recovers, because that keyword sits directly after the dict. r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - // Technically not allowed, but no reason to not try it. - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let tail = r.tail()?; let endstream_offset = find_needle(tail, b"endstream")?; diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index 20d6adef..7f1740f4 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -604,9 +604,17 @@ impl<'a> Page<'a> { { (A4.width() as f32, A4.height() as f32) } else { + // PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit. + // The stored unit is finite and within 1..=75000. Round upward only if the + // reciprocal's f32 product would fall below one and trigger a placeholder. + let mut raw_floor = 1.0 / self.user_unit; + if raw_floor * self.user_unit < 1.0 { + raw_floor = raw_floor.next_up(); + } + let raw_floor = f64::from(raw_floor); ( - crop_box.width().max(1.0) as f32, - crop_box.height().max(1.0) as f32, + crop_box.width().max(raw_floor) as f32, + crop_box.height().max(raw_floor) as f32, ) } } diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index 50e0340a..737e78b5 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro", "files": [ { @@ -60,15 +60,15 @@ }, { "path": "src/lib.rs", - "source_sha256": "1ce3e02072acb639c33476618446af80fca29b960a3ca764168c1c2d348d5e5d", - "source_git_blob": "def102b4291b1fe99f2c8975477e5d294c4f955f", - "sha256": "1ce3e02072acb639c33476618446af80fca29b960a3ca764168c1c2d348d5e5d" + "source_sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078", + "source_git_blob": "6333ed5808ae0cf9cf158e4730e973c1d7089bab", + "sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078" }, { "path": "src/renderer.rs", - "source_sha256": "b74b630b73e86b69b26ef433ee6aabbb684def133e4ae46f0cbf8f9f1fbd4484", - "source_git_blob": "23fc2e36cae7368e1a508bb246f3a1a15bb60a67", - "sha256": "b74b630b73e86b69b26ef433ee6aabbb684def133e4ae46f0cbf8f9f1fbd4484" + "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", + "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", + "sha256": "81ac25ccc066b4e84e7dedf3abee813948e443c87a32f9f28cb067c3a383904d" } ] } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index def102b4..6333ed58 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode}; mod renderer; /// PdfCraft patch: exported for its regression test. -pub use renderer::{image_resampling_size, tiling_cell_scale}; +pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale}; /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; @@ -254,6 +254,16 @@ pub fn render_into<'a>( interpreter_settings.preview, ); + // PdfCraft patch (18): the page renders as one transparency group with the background + // painted *inside* it, as the initial backdrop. A group isolates what it wraps from what + // was painted before it, so with the background outside, a Multiply highlight blended + // only with the group's transparent backdrop and stayed its own colour — over a dark + // reading-colours background the whole quad became one flat bar and the words under it + // vanished. Inside the group the highlight multiplies with the background too: the words + // keep the highlight's colour and the band between them takes the background (white + // backgrounds are unaffected — multiplying with white is the identity). + device.push_transparency_group(1.0, None, BlendMode::Normal); + device.ctx.set_paint(render_settings.bg_color); device .ctx @@ -265,12 +275,10 @@ pub fn render_into<'a>( fill: FillRule::NonZero, }); - device.push_transparency_group(1.0, None, BlendMode::Normal); interpret_page(page, &mut state, &mut device); - device.pop_transparency_group(); - device.pop_clip_path(); + device.pop_transparency_group(); let mut resources = vello_cpu::Resources::default(); device.ctx.render_to_buffer( diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 23fc2e36..fafe4d79 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -432,6 +432,17 @@ pub(crate) struct Renderer { /// PdfCraft patch: when set, solid text and line-art paint colours are remapped /// while rendering (the "reading colours" view setting). color_replacement: Option, + /// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their + /// colours — reading colours replace document colours, never annotation colours. + in_annotations: bool, + /// Knockout patch (pdfcraft #2974): one entry per transparency group pushed, whether it is a + /// knockout group (`Device::set_knockout`). + knockout: Vec, + /// Knockout patch (pdfcraft #2974): inside a knockout erase pass + /// (`Device::begin_knockout_erase`), and how many transparency groups were skipped in it + /// (nothing is layered while erasing). + erasing: bool, + erase_groups: usize, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -455,6 +466,12 @@ enum ImagePixelFormat { } impl Renderer { + /// Knockout patch (pdfcraft #2974): whether the innermost transparency group is a knockout + /// group. + fn in_knockout(&self) -> bool { + self.knockout.last().copied().unwrap_or(false) + } + pub(crate) fn new( width: u16, height: u16, @@ -466,6 +483,10 @@ impl Renderer { Self { ctx: RenderContext::new_with(width, height, settings), color_replacement, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: cache.outline_cache.clone(), @@ -522,14 +543,7 @@ impl Renderer { }, }; - // A miter limit that isn't finite leaves miters unbounded: never skipped. - let miter = stroke.miter_limit.abs(); - let reach = stroke.width / 2.0 - * if miter.is_finite() { - miter.max(std::f64::consts::SQRT_2) - } else { - f64::INFINITY - }; + let reach = stroke_reach(&stroke); self.ctx.set_stroke(stroke); reach } @@ -573,6 +587,10 @@ impl Renderer { // PdfCraft patch: this renders an image's alpha mask, which must keep its // original values, so it is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -737,6 +755,40 @@ impl Renderer { } } + /// The size an image of `width` x `height` samples is decoded at to be drawn with + /// `transform`, or `None` if `draw_image` skips it: off the canvas (PdfCraft patch: see + /// `may_paint_rect`; checked before decoding, so a tile no longer decodes every image on its + /// page), or, in a preview, covering only a few device pixels (PdfCraft patch; final renders + /// always draw them). The tiny-image test measures axis lengths: the size returned maps a + /// point, so it includes the translation and only suits the decode hint. + fn image_draw_size(&self, width: u32, height: u32, transform: Affine) -> Option<(u32, u32)> { + if !may_paint_rect( + Rect::new(0.0, 0.0, f64::from(width), f64::from(height)), + transform, + self.ctx.width(), + self.ctx.height(), + ) { + return None; + } + let target_width = (transform * Point::new(width as f64, 0.0)) + .to_vec2() + .length() + .ceil() as u32; + let target_height = (transform * Point::new(0.0, height as f64)) + .to_vec2() + .length() + .ceil() as u32; + if self.preview { + let c = transform.as_coeffs(); + let tiny_w = (f64::from(width) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32; + let tiny_h = (f64::from(height) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32; + if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA { + return None; + } + } + Some((target_width, target_height)) + } + fn draw_image(&mut self, image_data: ImageData, alpha_data: Option) { let cur_transform = *self.ctx.transform(); let (x_scale, y_scale) = { @@ -1023,6 +1075,11 @@ impl Renderer { let Some(replacement) = self.color_replacement else { return paint.clone(); }; + // PdfCraft patch (17): annotations keep the colours they were given; reading colours + // replace document colours only (issue #1548). + if self.in_annotations { + return paint.clone(); + } match paint { Paint::Color(c) => { let mapped = replacement.map(c.to_rgba().to_rgba8(), is_text); @@ -1037,6 +1094,20 @@ impl Renderer { #[must_use] fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option { + // Knockout patch (pdfcraft #2974): in a knockout erase pass every paint clears what is + // under its shape (a transparent fill with `Compose::Copy`, weighted by coverage). + if self.erasing { + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + // A shading's `/BBox` clips what it paints, so it clips what it clears too. + return match paint { + Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(s) => s.shading.clip_path.clone(), + _ => None, + }, + _ => None, + }; + } let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; @@ -1075,8 +1146,13 @@ impl Renderer { // become native gradients instead of a texture sampled pixel by pixel. // Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already // evaluates gradients at pixel centres, like the texture below. - if let Some(native) = - encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE) + // PdfCraft patch (#2128): vello_cpu 0.0.8 copies an opaque gradient + // over fully covered tiles without applying the mask or blend mode, and + // a gradient can't be flagged transparent like the texture below, so + // under a soft mask or blend mode the shading is sampled instead. + if !self.force_images_may_have_transparency() + && let Some(native) = + encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE) { self.ctx .set_paint_transform(path_transform.inverse() * native.transform); @@ -1127,6 +1203,68 @@ impl Renderer { }; xs = xs.max(min_x_scale).min(max_x_scale); ys = ys.max(min_y_scale).min(max_y_scale); + // PdfCraft patch (20): a step too large for a cell pixmap, where what is + // painted can only show the first cell (PowerPoint writes `/XStep + // 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is + // drawn at full scale with a transparent border and padded, not tiled. + // Shrinking the cell to fit the step left a picture under one pixel. + // How far a stroke's paint reaches past the path, miters included, in + // device pixels: the most the transform stretches any direction (a skew + // stretches some further than either axis), times the reach. + let reach = if is_stroke { + path_transform.spectral_norm() * stroke_reach(self.ctx.stroke()) + } else { + 0.0 + }; + if usable_cell_scale(xs, ys) + && (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE) + && shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step) + { + const BORDER: f64 = 1.0; + let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2); + let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2); + let mut renderer = Self { + ctx: RenderContext::new_with( + pix_width, + pix_height, + derive_settings(self.ctx.render_settings()), + ), + color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, + cur_mask: None, + inside_pattern: true, + soft_mask_cache: HashMap::default(), + outline_cache: self.outline_cache.clone(), + in_type3_glyph: false, + scaler: self.scaler, + preview: self.preview, + image_transparency_stack: Vec::new(), + }; + let initial_transform = Affine::translate((BORDER, BORDER)) + * Affine::scale_non_uniform(xs as f64, ys as f64) + * Affine::translate((-bbox.x0, -bbox.y0)); + t.interpret(&mut renderer, initial_transform, is_stroke); + let mut pix = Pixmap::new(pix_width, pix_height); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse(); + let image = Image { + image: ImageSource::Pixmap(Arc::new(pix)), + sampler: ImageSampler { + x_extend: peniko::Extend::Pad, + y_extend: peniko::Extend::Pad, + quality: ImageQuality::Medium, + alpha: 1.0, + }, + }; + self.ctx.set_paint_transform(paint_transform); + self.ctx.set_paint(PaintType::Image(image)); + return clip_path; + } // PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the // u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells). xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE); @@ -1149,6 +1287,10 @@ impl Renderer { // PdfCraft patch: a pattern cell is painted as one artwork, so its // content is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1342,40 +1484,19 @@ impl Renderer { impl<'a> Device<'a> for Renderer { fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) { - // PdfCraft patch: see `may_paint_rect`. Checked before decoding: off-canvas images - // cost nothing, so a tile no longer decodes every image on its page. - if !may_paint_rect( - Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())), - transform, - self.ctx.width(), - self.ctx.height(), - ) { + // Knockout patch (pdfcraft #2974): an erase pass paints nothing; an unmasked image's + // rectangle was cleared by `knockout_shape`, and a masked image's shape is left alone. + if self.erasing { return; } + let Some((target_width, target_height)) = + self.image_draw_size(image.width(), image.height(), transform) + else { + return; + }; self.ctx.set_paint_transform(Affine::IDENTITY); self.ctx.set_aliasing_threshold(Some(1)); - let target_width = (transform * Point::new(image.width() as f64, 0.0)) - .to_vec2() - .length() - .ceil() as u32; - let target_height = (transform * Point::new(0.0, image.height() as f64)) - .to_vec2() - .length() - .ceil() as u32; - - // PdfCraft patch: previews skip images that would cover only a few device pixels. - // Final renders always draw them. (Measured as axis lengths: the `target_*` above - // map a point, so they include the translation and only suit the decode hint.) - if self.preview { - let c = transform.as_coeffs(); - let tiny_w = (f64::from(image.width()) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32; - let tiny_h = (f64::from(image.height()) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32; - if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA { - return; - } - } - match image { hayro_interpret::Image::Stencil(s) => { s.with_stencil( @@ -1464,6 +1585,10 @@ impl<'a> Device<'a> for Renderer { // coverage values, so its content is never remapped by // the reading-colours setting. color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1536,6 +1661,11 @@ impl<'a> Device<'a> for Renderer { mask: Option>, blend_mode: BlendMode, ) { + // Knockout patch (pdfcraft #2974): nothing is layered in a knockout erase pass. + if self.erasing { + self.erase_groups += 1; + return; + } let settings = *self.ctx.render_settings(); let force_images_may_have_transparency = mask.is_some() || blend_mode != BlendMode::Normal @@ -1562,6 +1692,7 @@ impl<'a> Device<'a> for Renderer { ); self.image_transparency_stack .push(force_images_may_have_transparency); + self.knockout.push(false); } fn pop_clip_path(&mut self) { @@ -1569,10 +1700,86 @@ impl<'a> Device<'a> for Renderer { } fn pop_transparency_group(&mut self) { + if self.erasing && self.erase_groups > 0 { + self.erase_groups -= 1; + return; + } self.image_transparency_stack.pop(); + self.knockout.pop(); self.ctx.pop_layer(); } + /// Knockout patch (pdfcraft #2974): the group just pushed is a knockout group. Its elements are + /// drawn with `Compose::Copy`, which (weighted by coverage) replaces what earlier elements drew + /// where the element's shape is, so each element composites with the group's initial backdrop + /// (transparent: an isolated group, and the non-isolated approximation every group layer here + /// already makes) instead of with the elements before it. Against a transparent backdrop the + /// element's own blend mode has nothing to blend with, so the mix is Normal. This covers paths, + /// strokes, glyph outlines and shadings. Images and nested groups are drawn into a layer of + /// their own, which can't be copied (it covers the whole canvas, transparent where nothing was + /// drawn): what is under their shape is cleared first (`knockout_shape`, the knockout erase + /// pass) and the layer composites over it. + fn set_knockout(&mut self) { + if let Some(top) = self.knockout.last_mut() { + *top = true; + self.ctx.set_blend_mode(knockout_blend_mode()); + } + } + + /// Knockout patch (pdfcraft #2974): in a knockout group, clear what the group drew under + /// `shape` (a transparent fill with `Compose::Copy`, weighted by coverage) before an element + /// drawn into a layer of its own composites there, so the element replaces it. + fn knockout_erase_wanted(&mut self) -> bool { + self.in_knockout() && !self.erasing + } + + fn begin_knockout_erase(&mut self) { + self.erasing = true; + self.erase_groups = 0; + } + + fn end_knockout_erase(&mut self) { + self.erasing = false; + self.erase_groups = 0; + self.ctx.set_blend_mode(knockout_blend_mode()); + } + + fn in_knockout_group(&mut self) -> bool { + self.in_knockout() + } + + fn knockout_image(&mut self, image: &hayro_interpret::RasterImage<'_>, transform: Affine) { + if !self.in_knockout() { + return; + } + // Only an image `draw_image` will draw, decoded at the size it draws it (the same + // decode, so a cached decode serves both). + let Some(size) = self.image_draw_size(image.width(), image.height(), transform) else { + return; + }; + let mut decodes = false; + image.with_rgba(|_, _| decodes = true, Some(size)); + if decodes { + let rect = Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())); + self.knockout_shape(&(transform * rect.to_path(0.1))); + } + } + + fn knockout_shape(&mut self, shape: &BezPath) { + if !self.in_knockout() { + return; + } + let old_transform = *self.ctx.transform(); + let old_rule = *self.ctx.fill_rule(); + self.ctx.set_transform(Affine::IDENTITY); + self.ctx.set_fill_rule(Fill::NonZero); + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + self.ctx.fill_path(shape); + self.ctx.set_fill_rule(old_rule); + self.ctx.set_transform(old_transform); + } + fn set_soft_mask(&mut self, mask: Option>) { let settings = *self.ctx.render_settings(); self.cur_mask = mask.map(|m| { @@ -1608,6 +1815,16 @@ impl<'a> Device<'a> for Renderer { } } + /// PdfCraft patch (17): the reading-colours mapping is suspended for annotations. + fn begin_annotations(&mut self) { + self.in_annotations = true; + } + + /// PdfCraft patch (17): back to document content; reading colours apply again. + fn end_annotations(&mut self) { + self.in_annotations = false; + } + fn draw_rect( &mut self, rect: &Rect, @@ -1666,7 +1883,14 @@ impl<'a> Device<'a> for Renderer { } fn set_blend_mode(&mut self, blend_mode: BlendMode) { - self.ctx.set_blend_mode(convert_blend_mode(blend_mode)); + // Knockout patch (pdfcraft #2974): inside a knockout group every element replaces what it + // covers. + let mode = if self.in_knockout() { + knockout_blend_mode() + } else { + convert_blend_mode(blend_mode) + }; + self.ctx.set_blend_mode(mode); } } @@ -1749,6 +1973,10 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei // PdfCraft patch: a soft mask must keep its original luminance values, so its // content is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), @@ -1843,6 +2071,12 @@ fn convert_fill_rule(fill_rule: FillRule) -> Fill { } } +/// Knockout patch (pdfcraft #2974): how an element of a knockout group is drawn (see +/// `set_knockout`). +fn knockout_blend_mode() -> peniko::BlendMode { + peniko::BlendMode::new(Mix::Normal, Compose::Copy) +} + fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { let mix = match blend_mode { BlendMode::Normal => Mix::Normal, @@ -1866,6 +2100,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { peniko::BlendMode::new(mix, Compose::SrcOver) } +/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width +/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that +/// isn't finite leaves miters unbounded, so the reach is too. +fn stroke_reach(stroke: &kurbo::Stroke) -> f64 { + let miter = stroke.miter_limit.abs(); + stroke.width / 2.0 + * if miter.is_finite() { + miter.max(std::f64::consts::SQRT_2) + } else { + f64::INFINITY + } +} + +/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A +/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale, +/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path. +pub fn usable_cell_scale(xs: f32, ys: f32) -> bool { + xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0 +} + +/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach` +/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can +/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more +/// away. False when the matrix can't be inverted or the bounds aren't finite. +pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool { + if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() { + return false; + } + let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach); + let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box(); + let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs())); + let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite()); + // Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other. + finite + && bounds.x1 < bbox.x0 + sx + && bounds.x0 > bbox.x1 - sx + && bounds.y1 < bbox.y0 + sy + && bounds.y0 > bbox.y1 - sy +} + /// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| × /// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform /// is derived from the scale); an over-large step only renders at lower resolution.