Merge craft-prepress into the print color PR

This commit is contained in:
aiongg committed 2026-10-20 03:51:59 +00:00
commit 4026931196
50 files changed
+5208 -459

No files matched your search

+7 -4
View File
@@ -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
Generated
+9
View File
@@ -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"
+1
View File
@@ -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" }
+9 -1
View File
@@ -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,
+1 -1
View File
@@ -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.
+2
View File
@@ -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<ColorReplacement>,
}
+162
View File
@@ -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<u8> {
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<u8> {
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");
}
+477
View File
@@ -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<u8>]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
out.extend_from_slice(
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
);
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for (i, body) in objects.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b" endobj\n");
}
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
out
}
/// A stream object body with `dict` entries and `data`.
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes();
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream");
out
}
fn render(pdf: &[u8], settings: &InterpreterSettings) -> Vec<u8> {
let doc = open_slice(pdf, None).unwrap();
let options = Options { background: [255; 4], ..Default::default() };
let raster = raster::render(&doc.pages()[0], &RenderCache::new(), settings, &options).unwrap();
assert_eq!((raster.width, raster.height), (40, 40));
raster.bytes().to_vec()
}
/// One film per ink, as RGBA8 over white.
struct Plates(Vec<Vec<u8>>);
impl Plates {
/// Each ink's tint at device pixel (x, y), in whole percent.
fn at(&self, x: usize, y: usize) -> Vec<u32> {
self.0
.iter()
.map(|film| {
let px = &film[(y * 40 + x) * 4..][..4];
assert_eq!((px[0], px[3]), (px[2], 255), "a film is opaque gray");
(f32::from(255 - px[0]) * 100.0 / 255.0).round() as u32
})
.collect()
}
}
fn plates(pdf: &[u8], inks: &[Ink], overprint: bool, simulation: Option<&CmykSimulation>) -> Plates {
Plates(
inks.iter()
.map(|ink| {
let plate = Plate { ink: ink.clone(), overprint, simulation: simulation.cloned() };
render(pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() })
})
.collect(),
)
}
fn spot(name: &str) -> Ink {
Ink::Spot(name.into())
}
/// The four process plates, then `spots`.
fn inks(spots: &[&str]) -> Vec<Ink> {
Ink::PROCESS.into_iter().chain(spots.iter().map(|s| spot(s))).collect()
}
/// The process plates and Gold, overprint 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<Mutex<Seen>> = Arc::default();
let log = seen.clone();
let sink: InkSinkFn = Arc::new(move |ink: &Ink, appearance: &dyn Fn(f32) -> [u8; 3]| {
let mut log = log.lock().unwrap();
if !log.iter().any(|(seen, _)| seen == ink) {
log.push((ink.clone(), appearance(1.0)));
}
});
render(&page_pdf(content, resources, &[image, cell, mask_group]), &InterpreterSettings { ink_sink: Some(sink), ..Default::default() });
let seen = seen.lock().unwrap();
let names: Vec<&str> = seen.iter().map(|(ink, _)| ink.name()).collect();
assert_eq!(names, ["Silver", "Gold"], "in the order first painted; a mask's ink doesn't print");
assert_eq!(seen[0].1, [128, 128, 128], "Silver at 100% is its alternate color");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn malformed_color_spaces_and_overprint_parameters_draw_what_they_can() {
let resources = "/ColorSpace << /A [/DeviceN [] /DeviceGray 0] /B [/Separation] /C [/Indexed /DeviceCMYK 300 <00>] >> /ExtGState << /G << /OP 7 /OPM /X /op (y) >> >>";
let pdf = page_pdf("/G gs /A cs 1 scn 0 0 9 9 re f /B cs 1 scn /C cs 255 scn 0 0 40 40 re f", resources, &[]);
plates(&pdf, &inks(&["Gold"]), true, None);
}
// Separating through an output intent's CMYK profile (vendored patch 36).
const PRESS: &str = "PdfCraft synthetic press";
fn press_simulation() -> CmykSimulation {
CmykSimulation::new(&synthetic_icc::cmyk_profile(PRESS, true)).unwrap()
}
/// What the synthetic press prints for CIE L*a*b*, in whole percent.
fn press(l: f64, a: f64, b: f64) -> Vec<u32> {
synthetic_icc::btoa(l, a, b).iter().map(|v| (v * 100.0).round() as u32).collect()
}
/// Whether every ink is within `tolerance` percent of `expected`.
fn near(got: &[u32], expected: &[u32], tolerance: u32) -> bool {
got.len() == expected.len() && got.iter().zip(expected).all(|(g, e)| g.abs_diff(*e) <= tolerance)
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn rgb_separates_through_the_simulated_cmyk_profile() {
let simulation = press_simulation();
assert_eq!(simulation.description(), Some(PRESS));
assert!(!simulation.perceptual());
let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]);
// The press prints a neutral with equal inks, where the device formulas put it on black
// alone.
let gray = press(synthetic_icc::srgb_gray_lightness(0.5), 0.0, 0.0);
let got = plates(&pdf, &inks(&[]), true, Some(&simulation)).at(20, 20);
assert!(near(&got, &gray, 2), "{got:?}, the press prints {gray:?}");
assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [0, 0, 0, 50]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn only_cmyk_profiles_with_pcs_to_device_tables_simulate() {
let whole = synthetic_icc::cmyk_profile(PRESS, true);
assert_eq!(CmykSimulation::new(&synthetic_icc::gray_profile("Gray")).err(), Some(NoSimulation::NotCmyk));
assert_eq!(CmykSimulation::new(&synthetic_icc::cmyk_profile("Proofing only", false)).err(), Some(NoSimulation::NoPcsToDevice));
assert_eq!(CmykSimulation::new(b"not a profile").err(), Some(NoSimulation::Unreadable));
assert_eq!(CmykSimulation::new(&whole[..300]).err(), Some(NoSimulation::Unreadable));
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn cmyk_in_the_simulated_profile_passes_through_and_other_cmyk_converts() {
let profile = synthetic_icc::cmyk_profile(PRESS, true);
let simulation = CmykSimulation::new(&profile).unwrap();
// Object 5 is the press profile, 6 another CMYK profile.
let resources = "/ColorSpace << /Same [/ICCBased 5 0 R] /Other [/ICCBased 6 0 R] >>";
let content = "0.2 0.4 0.6 0.8 k 0 0 10 40 re f /Same cs 0.1 0.3 0.5 0.7 scn 10 0 10 40 re f /Other cs 0 0 0 1 scn 30 0 10 40 re f";
let pdf = page_pdf(content, resources, &[stream("/N 4", &profile), stream("/N 4", CGATS)]);
let s = plates(&pdf, &inks(&[]), true, Some(&simulation));
assert_eq!(s.at(5, 20), [20, 40, 60, 80], "DeviceCMYK is the press's own CMYK");
assert_eq!(s.at(15, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile");
let other = s.at(35, 20);
assert!(other.iter().all(|v| *v > 70), "black alone in another profile is a four-ink black on this press: {other:?}");
assert_eq!(plates(&pdf, &inks(&[]), true, None).at(35, 20), [0, 0, 0, 100], "without a simulation ICC CMYK is taken as it is");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn device_gray_stays_on_the_black_plate_under_a_simulation() {
let s = plates(&page_pdf("0.25 g 0 0 40 40 re f", "", &[]), &inks(&[]), true, Some(&press_simulation()));
assert_eq!(s.at(20, 20), [0, 0, 0, 75]);
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn cie_and_icc_colors_convert_through_the_profile_relative_to_their_white() {
let resources =
"/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Gray [/ICCBased 5 0 R] >>";
let content = "/D50 cs 50 20 -10 scn 0 0 10 40 re f /D65 cs 100 0 0 scn 10 0 10 40 re f /Gray cs 0.5 scn 20 0 10 40 re f";
let pdf = page_pdf(content, resources, &[stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2"))]);
let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation()));
assert!(near(&s.at(5, 20), &press(50.0, 20.0, -10.0), 2), "Lab: {:?}", s.at(5, 20));
assert_eq!(s.at(15, 20), [0, 0, 0, 0], "a Lab space's own white prints as paper, whatever its white point");
let lightness = 116.0 * 0.5_f64.powf(2.2).cbrt() - 16.0;
assert!(near(&s.at(25, 20), &press(lightness, 0.0, 0.0), 2), "ICC gray: {:?}", s.at(25, 20));
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn images_convert_through_the_simulation_sample_by_sample() {
let rgb = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB", &[128, 128, 128, 255, 255, 255]);
// More samples than palette entries: the palette is separated once.
let indexed = stream(
"/Type /XObject /Subtype /Image /Width 4 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 <808080FFFFFF>]",
&[0, 0, 1, 1],
);
let content = "q 40 0 0 20 0 20 cm /Im1 Do Q q 40 0 0 20 0 0 cm /Im2 Do Q";
let pdf = page_pdf(content, "/XObject << /Im1 5 0 R /Im2 6 0 R >>", &[rgb, indexed]);
let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation()));
let gray = press(synthetic_icc::srgb_gray_lightness(128.0 / 255.0), 0.0, 0.0);
for y in [10, 30] {
assert!(near(&s.at(10, y), &gray, 2), "y = {y}: {:?}, the press prints {gray:?}", s.at(10, y));
assert_eq!(s.at(30, y), [0, 0, 0, 0], "y = {y}: white prints no ink");
}
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_simulation_shows_its_cmyk_through_its_own_profile() {
// The inks the press prints sRGB gray 128 with show as that gray: its two tables invert
// each other.
let ink = (255.0 * (1.0 - synthetic_icc::srgb_gray_lightness(128.0 / 255.0) / 100.0)).round() as u8;
let mut shown = [0; 3];
press_simulation().cmyk_to_srgb8(&[ink; 4], &mut shown).unwrap();
assert!(shown.iter().all(|c| c.abs_diff(128) <= 4), "{shown:?}");
assert!(press_simulation().cmyk_to_srgb8(&[0; 4], &mut [0; 4]).is_none(), "lengths disagree");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn mutated_profiles_simulate_or_are_refused_without_panicking() {
// Bytes through the profile's header, tag table and tables changed in turn. Making a
// simulation takes a third of a second in a debug build, so the bytes are sampled.
let whole = synthetic_icc::cmyk_profile(PRESS, true);
let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]);
let positions = (0..whole.len().min(256)).step_by(16).chain((256..whole.len()).step_by(9973));
for (n, at_byte) in positions.enumerate() {
let mut bent = whole.clone();
bent[at_byte] = [0x00, 0x7F, 0xFF][n % 3];
if let Ok(simulation) = CmykSimulation::new(&bent) {
let plate = Plate { ink: Ink::Cyan, overprint: true, simulation: Some(simulation) };
render(&pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() });
}
}
}
/// A caller's conversion: sRGB prints as 10/20/30/40%, XYZ as 50/60/70/80%, and the XYZ it is
/// given is kept.
struct Fixed(Arc<Mutex<Vec<[f32; 3]>>>);
impl CmykConversion for Fixed {
fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> {
(rgb.len() / 3 == cmyk.len() / 4).then(|| cmyk.as_chunks_mut::<4>().0.fill([0.1, 0.2, 0.3, 0.4]))
}
fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> {
self.0.lock().unwrap().extend(xyz.as_chunks::<3>().0.iter().copied());
cmyk.as_chunks_mut::<4>().0.fill([0.5, 0.6, 0.7, 0.8]);
Some(())
}
fn description(&self) -> Option<&str> {
Some("fixed")
}
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn a_callers_cmyk_conversion_separates_process_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");
}
+227
View File
@@ -0,0 +1,227 @@
//! Synthetic ICC profiles for the plate-mode tests, written byte by byte from ICC.1 (version
//! 2.1 profiles with `lut16Type` tables and the legacy 16-bit PCS L*a*b* encoding). They are
//! contributor-original and generated when a test runs; no profile file is committed.
//!
//! The CMYK printing condition is made up so that its conversions can be worked out by hand:
//!
//! - AToB (CMYK → PCS L*a*b*): L* = 100 · (1 − (c + m + y + k) / 4), a* = 60 · (m − c),
//! b* = 60 · (y − (c + m) / 2). It is affine, so a two-point grid holds it exactly.
//! - BToA (PCS → CMYK), its inverse with equal inks along the neutral axis: with
//! t = 1 − L* / 100, c = t − a* / 120, m = t + a* / 120, y = t + b* / 60, k = t − b* / 60, each
//! clamped to 0..1. A neutral of L* 50 prints as 50% of each ink, where the ISO 32000-2
//! §10.4.2.4 device formulas would print it on black alone.
//!
//! From PdfCraft #2770 by Guillem Hermida (`crates/render/tests/support/synthetic_icc.rs` at
//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0). Used by `tests/plates.rs`
//! through `#[path]`.
#![allow(dead_code)]
/// Grid points per input of the BToA table: fine enough that 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<u8> {
let atob_table = lut16(4, 3, 2, false, |node| {
let cmyk = [node[0], node[1], node[2], node[3]];
encode_lab(atob(cmyk))
});
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"desc", text_description(description)),
(b"cprt", text(b"No copyright, synthetic test data")),
(b"wtpt", xyz(D50)),
(b"A2B0", atob_table.clone()),
(b"A2B1", atob_table.clone()),
(b"A2B2", atob_table),
];
if btoa_tables {
let btoa_table = lut16(3, 4, BTOA_GRID, true, |node| {
let [l, a, b] = grid_lab([node[0], node[1], node[2]]);
btoa(l, a, b).map(|v| v * 65535.0).to_vec()
});
tags.push((b"B2A0", btoa_table.clone()));
tags.push((b"B2A1", btoa_table.clone()));
tags.push((b"B2A2", btoa_table));
// Everything is in gamut.
tags.push((b"gamt", lut16(3, 1, 2, false, |_| vec![0.0])));
}
profile(b"prtr", b"CMYK", b"Lab ", &tags)
}
/// A gray display profile (gamma 2.2), described as `description`: an output intent that is
/// not CMYK.
pub fn gray_profile(description: &str) -> Vec<u8> {
// curveType with one entry: a gamma as u8Fixed8Number (2.2 ≈ 0x0233).
let mut curve = b"curv\0\0\0\0".to_vec();
curve.extend_from_slice(&1_u32.to_be_bytes());
curve.extend_from_slice(&0x0233_u16.to_be_bytes());
let tags: Vec<(&[u8; 4], Vec<u8>)> =
vec![(b"desc", text_description(description)), (b"cprt", text(b"No copyright, synthetic test data")), (b"wtpt", xyz(D50)), (b"kTRC", curve)];
profile(b"mntr", b"GRAY", b"XYZ ", &tags)
}
/// The D50 illuminant of the PCS.
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// PCS L*a*b* in the legacy 16-bit encoding (ICC.1 Annex A), as `0.0..=65535.0` table values:
/// L* 100 is 0xFF00 and a* or b* 0 is 0x8000.
fn encode_lab([l, a, b]: [f64; 3]) -> Vec<f64> {
vec![l * 65280.0 / 100.0, (a + 128.0) * 256.0, (b + 128.0) * 256.0].into_iter().map(|v| v.clamp(0.0, 65535.0)).collect()
}
/// A BToA grid point's PCS L*a*b*: `node` holds its inputs as `0.0..=1.0`. The L* input
/// table has stretched L* 100 (0xFF00) onto the last grid point, so white prints no ink; a*
/// and b* are in the legacy encoding.
fn grid_lab(node: [f64; 3]) -> [f64; 3] {
let [l, a, b] = node;
[l * 100.0, a * 65535.0 / 256.0 - 128.0, b * 65535.0 / 256.0 - 128.0]
}
/// A `lut16Type` with `inputs` × `outputs` channels, `grid` points per input, identity
/// matrix and output tables, and the grid filled by `value` (each grid point's inputs as
/// `0.0..=1.0`, returning its outputs as `0.0..=65535.0`). The input tables are the identity,
/// except that `stretch_lightness` scales the first input by 0xFFFF / 0xFF00 (see [`grid_lab`]).
fn lut16(inputs: usize, outputs: usize, grid: usize, stretch_lightness: bool, value: impl Fn(&[f64]) -> Vec<f64>) -> Vec<u8> {
const ENTRIES: usize = 257;
let mut out = b"mft2\0\0\0\0".to_vec();
out.extend_from_slice(&[inputs as u8, outputs as u8, grid as u8, 0]);
for k in 0_usize..9 {
let one = if k.is_multiple_of(4) { 0x0001_0000_i32 } else { 0 };
out.extend_from_slice(&one.to_be_bytes());
}
out.extend_from_slice(&(ENTRIES as u16).to_be_bytes());
out.extend_from_slice(&2_u16.to_be_bytes());
for channel in 0..inputs {
for e in 0..ENTRIES {
let x = e as f64 / (ENTRIES - 1) as f64;
let y = if stretch_lightness && channel == 0 { (x * 65535.0 / 65280.0).min(1.0) } else { x };
out.extend_from_slice(&((y * 65535.0).round() as u16).to_be_bytes());
}
}
// The first input varies slowest.
let points = grid.pow(inputs as u32);
for index in 0..points {
let mut node = vec![0.0; inputs];
let mut rest = index;
for i in (0..inputs).rev() {
node[i] = (rest % grid) as f64 / (grid - 1) as f64;
rest /= grid;
}
let values = value(&node);
assert_eq!(values.len(), outputs);
for v in values {
out.extend_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_be_bytes());
}
}
for _ in 0..outputs {
out.extend_from_slice(&[0, 0, 0xFF, 0xFF]);
}
out
}
/// A `textDescriptionType` (ICC.1:2001, the version 2 `desc` tag) with an ASCII description.
fn text_description(description: &str) -> Vec<u8> {
let ascii: Vec<u8> = description.bytes().filter(u8::is_ascii).collect();
let mut out = b"desc\0\0\0\0".to_vec();
out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes());
out.extend_from_slice(&ascii);
out.push(0);
out.extend_from_slice(&[0; 4]); // Unicode language code
out.extend_from_slice(&[0; 4]); // Unicode count
out.extend_from_slice(&[0; 2]); // ScriptCode code
out.push(0); // ScriptCode count
out.extend_from_slice(&[0; 67]);
out
}
/// A `textType`.
fn text(ascii: &[u8]) -> Vec<u8> {
let mut out = b"text\0\0\0\0".to_vec();
out.extend_from_slice(ascii);
out.push(0);
out
}
fn s15_fixed16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
/// An `XYZType` with one value.
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut out = b"XYZ \0\0\0\0".to_vec();
for c in v {
out.extend_from_slice(&s15_fixed16(c));
}
out
}
/// A whole version 2.1 profile: the 128-byte header, the tag table, then each tag's data,
/// 4-byte aligned. Tags with equal data share it.
fn profile(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let table_end = 128 + 4 + 12 * tags.len();
let mut data: Vec<u8> = Vec::new();
let mut entries: Vec<(&[u8; 4], usize, usize)> = Vec::new();
for (signature, body) in tags {
let shared = entries.iter().zip(tags).find(|(_, (_, b))| b == body).map(|((_, offset, len), _)| (*offset, *len));
let (offset, len) = match shared {
Some(place) => place,
None => {
while !data.len().is_multiple_of(4) {
data.push(0);
}
let offset = table_end + data.len();
data.extend_from_slice(body);
(offset, body.len())
}
};
entries.push((signature, offset, len));
}
while !data.len().is_multiple_of(4) {
data.push(0);
}
let size = table_end + data.len();
let mut out = Vec::with_capacity(size);
out.extend_from_slice(&(size as u32).to_be_bytes());
out.extend_from_slice(&[0; 4]); // preferred CMM
out.extend_from_slice(&0x0210_0000_u32.to_be_bytes());
out.extend_from_slice(class);
out.extend_from_slice(space);
out.extend_from_slice(pcs);
for v in [2026_u16, 10, 16, 0, 0, 0] {
out.extend_from_slice(&v.to_be_bytes());
}
out.extend_from_slice(b"acsp");
out.extend_from_slice(&[0; 4 + 4 + 4 + 4 + 8 + 4]); // platform, flags, manufacturer, model, attributes, intent
for c in D50 {
out.extend_from_slice(&s15_fixed16(c));
}
out.extend_from_slice(&[0; 4 + 16 + 28]); // creator, ID, reserved
out.extend_from_slice(&(tags.len() as u32).to_be_bytes());
for (signature, offset, len) in &entries {
out.extend_from_slice(*signature);
out.extend_from_slice(&(*offset as u32).to_be_bytes());
out.extend_from_slice(&(*len as u32).to_be_bytes());
}
out.extend_from_slice(&data);
out
}
+16
View File
@@ -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 }
+169
View File
@@ -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.
+161
View File
@@ -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<Option<Arc<Transform>>>,
from_lab: OnceLock<Option<Arc<Transform>>>,
to_srgb: OnceLock<Option<Arc<Transform>>>,
}
/// 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, ProfileError> {
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<Self, ProfileError> {
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<Option<Arc<Transform>>>, src: &Profile, dst: &Profile, bpc: bool) -> Option<Arc<Transform>> {
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<f32> = 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<Option<Arc<Transform>>> = 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<Option<Arc<Transform>>> = 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<f32> = 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(())
}
+134
View File
@@ -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<Vec<[u8; 3]>> {
let mut rgb = vec![0_u8; 256 * 3];
match alternate {
Alternate::Lab([l, a, b]) => {
let lab: Vec<f32> = (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<u8> = (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<u8> {
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<Option<&[u8]>> = 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<u8> = 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);
}
}
+295
View File
@@ -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<f32>,
/// 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<InkStats>,
/// 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<bool>,
/// 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<usize> {
(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<Item = &'a Vec<u8>> + '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<Coverage> {
let at = self.index_of(x, y)?;
let tints: Vec<u64> = 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<f32> {
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<u32> {
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<f32> {
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<bool> = 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<Region> {
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<u8>; 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());
}
}
+485
View File
@@ -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> {
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<f32>,
}
/// 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<Ink>,
aliases: BTreeMap<InkId, InkId>,
to_process: BTreeSet<InkId>,
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<InkId> {
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<InkId> {
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<Item = (InkId, &Ink)> {
// 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<Item = (InkId, &Spot)> {
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<InkId> {
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<Item = InkId> + '_ {
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<InkId> {
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<InkId> = 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"), "€");
}
}
+16
View File
@@ -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};
+320
View File
@@ -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<String>,
},
/// 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<String>,
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<Vec<u8>>,
}
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<u8> {
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<Plates, Error> {
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> = 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<raster::Raster, Error> {
// 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<CmykSimulation>, 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<PdfInk>,
}
fn spot_sink(log: &Arc<Mutex<SpotLog>>) -> 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<String> {
let s = dict.get::<PdfText<'_>>(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<u16> = 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<Array<'a>>) -> Vec<Dict<'a>> {
intents.iter().flat_map(|a| a.iter::<Dict<'a>>()).take(MAX_OUTPUT_INTENTS).collect()
}
let xref = pdf.xref();
let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)));
if listed.is_empty() {
listed = list(xref.get::<Dict<'_>>(xref.root_id()).and_then(|catalog| catalog.get::<Array<'_>>(OUTPUT_INTENTS)));
}
// A stable sort: the PDF/X intent first, the others in their order.
listed.sort_by_key(|d| d.get::<Name<'_>>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
listed.iter().find_map(|intent| {
let icc = intent.get::<Stream<'_>>(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);
}
}
+160
View File
@@ -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<u8>]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
out.extend_from_slice(format!("1 0 obj << /Type /Catalog /Pages 2 0 R {catalog} >> endobj\n").as_bytes());
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
out.extend_from_slice(
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
);
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for (i, body) in objects.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b" endobj\n");
}
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
out
}
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes();
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream");
out
}
/// A spot color whose alternate is a yellowish CMYK.
const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]";
/// Black over the page, Gold at 50% in the middle square, overprinting when `gs` sets `op`.
fn black_and_gold(gs: &str) -> Vec<u8> {
let resources = format!("/ColorSpace << /CS0 {GOLD} >> /ExtGState << /OP << /op true >> >>");
page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} /CS0 cs 0.5 scn 10 10 20 20 re f"), &resources, "", &[])
}
fn black() -> InkId {
InkId::process(Process::Black)
}
#[test]
fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() {
let p = plates(&black_and_gold(""), 0, 72.0, &Options::default()).unwrap();
assert_eq!((p.width, p.height, p.dpi), (40, 40, 72.0));
let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect();
assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
assert_eq!(p.planes.len(), 5);
assert!(p.unseparated.is_empty());
let gold = p.inks.lookup("Gold").unwrap();
let Some(Ink::Spot(spot)) = p.inks.get(gold) else { panic!("Gold is a spot ink") };
let Alternate::Lab([l, _, b]) = spot.alternate else { panic!("a Lab alternate") };
assert!(l > 60.0 && b > 40.0, "Gold looks yellow: {:?}", spot.alternate);
let half = |t: Option<u8>| t.is_some_and(|t| t.abs_diff(128) <= 1);
assert!(half(p.tint(gold, 20, 20)), "Gold at 50%");
assert_eq!(p.tint(black(), 20, 20), Some(0), "Gold knocks out black");
assert_eq!(p.tint(black(), 2, 2), Some(255));
assert_eq!(p.tint(gold, 2, 2), Some(0));
for c in [Process::Cyan, Process::Magenta, Process::Yellow] {
assert!(p.plane(InkId::process(c)).unwrap().iter().all(|t| *t == 0), "{c:?} is empty");
}
let over = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options::default()).unwrap();
assert!(half(over.tint(gold, 20, 20)));
assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted");
let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap();
assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out");
// The preview reads the plates: knocked-out Gold shows pale on paper, overprinted Gold
// darkens the black under it.
let cms = CmsConversion::fogra39();
let px = |rgb: &[u8], x: usize, y: usize| -> [u8; 3] { rgb[(y * 40 + x) * 3..][..3].try_into().unwrap() };
let (knockout, overprint) = (p.composite(&[], &cms), over.composite(&[], &cms));
let [r, g, b] = px(&knockout, 20, 20);
assert!(r > 200 && g > 180 && b < g, "50% Gold on paper: {:?}", [r, g, b]);
let k = px(&knockout, 2, 2);
assert!(k.iter().all(|c| *c < 80), "black: {k:?}");
assert_eq!(px(&overprint, 2, 2), k);
let dark = px(&overprint, 20, 20);
assert!(dark.iter().zip(k).all(|(d, k)| *d <= k) && dark[2] < k[2], "Gold over black: {dark:?}, black {k:?}");
}
/// What `profile` prints for sRGB red, as tint bytes.
fn red_in(profile: &[u8]) -> [u8; 4] {
let t = Transform::new(builtin::srgb(), &Profile::parse(profile).unwrap(), Intent::RelativeColorimetric, false).unwrap();
let mut cmyk = [0.0_f32; 4];
t.convert_f32(&[1.0, 0.0, 0.0], 3, &mut cmyk, 4, false).unwrap();
cmyk.map(|v| (v * 255.0).round() as u8)
}
fn red_tints(p: &craft_prepress::Plates) -> [u8; 4] {
Process::ALL.map(|c| p.tint(InkId::process(c), 20, 20).unwrap())
}
fn near(got: [u8; 4], expected: [u8; 4]) -> bool {
got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= 3)
}
#[test]
fn process_color_converts_through_the_output_intent_else_the_fallback() {
let synthetic = builtin::COATED_CMYK_ICC;
let intent = "/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Synthetic press) /DestOutputProfile 5 0 R >>]";
let red = |catalog: &str| page_pdf("1 0 0 rg 0 0 40 40 re f", "", catalog, &[stream("/N 4", synthetic)]);
let p = plates(&red(intent), 0, 72.0, &Options::default()).unwrap();
let profile = craft_cms::synth::DESCRIPTION.to_string();
assert_eq!(p.conversion, Conversion::OutputIntent { profile: profile.clone(), condition: Some("Synthetic press".into()) });
let expected = red_in(synthetic);
assert!(near(red_tints(&p), expected), "{:?}, the output intent prints {expected:?}", red_tints(&p));
let fogra = plates(&red(""), 0, 72.0, &Options::default()).unwrap();
assert_eq!(fogra.conversion, Conversion::Fallback { profile: builtin::COATED_FOGRA39_DESCRIPTION.into() });
let expected = red_in(builtin::COATED_FOGRA39_ICC);
assert!(near(red_tints(&fogra), expected), "{:?}, FOGRA39 prints {expected:?}", red_tints(&fogra));
let callers = Options { fallback: Fallback::Profile(Arc::from(synthetic)), ..Options::default() };
assert_eq!(plates(&red(""), 0, 72.0, &callers).unwrap().conversion, Conversion::Fallback { profile });
let formulas = Options { fallback: Fallback::DeviceFormulas, ..Options::default() };
let device = plates(&red(""), 0, 72.0, &formulas).unwrap();
assert_eq!((device.conversion.clone(), red_tints(&device)), (Conversion::DeviceFormulas, [0, 255, 255, 0]));
let broken = Options { fallback: Fallback::Profile(Arc::from(&b"not a profile"[..])), ..Options::default() };
assert!(matches!(plates(&red(""), 0, 72.0, &broken), Err(Error::Profile(_))));
}
#[test]
fn spot_inks_past_the_cap_are_listed_not_separated_and_bad_input_is_refused() {
let n = MAX_SPOT_INKS + 2;
let spaces: String =
(0..n).map(|i| format!("/S{i} [/Separation /Spot{i} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] ")).collect();
let content: String = (0..n).map(|i| format!("/S{i} cs 1 scn {i} 0 1 1 re f ")).collect();
let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]);
let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
assert_eq!((p.inks.len(), p.planes.len()), (4 + MAX_SPOT_INKS, 4 + MAX_SPOT_INKS));
assert_eq!(p.unseparated, [format!("Spot{}", n - 2), format!("Spot{}", n - 1)]);
assert!(matches!(plates(&pdf, 1, 72.0, &Options::default()), Err(Error::NoPage(1))));
assert!(matches!(plates(&pdf, 0, f32::NAN, &Options::default()), Err(Error::InvalidDpi)));
assert!(matches!(plates(b"not a pdf", 0, 72.0, &Options::default()), Err(Error::Open(_))));
}
#[test]
fn 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");
}
+52 -2
View File
@@ -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.
+47 -47
View File
@@ -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": {
+11 -1
View File
@@ -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<DecodedImageKey, Option<Box<dyn Any + Send + Sync>>>,
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::<T>())
+1113 -32
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -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`].
+41
View File
@@ -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.
+66 -37
View File
@@ -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<Triangle>, 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,
+23
View File
@@ -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.
+8
View File
@@ -546,6 +546,14 @@ impl StandardKind {
}
pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option<char> {
// 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)
}
+6
View File
@@ -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))
}
+16
View File
@@ -239,6 +239,14 @@ impl Type1Kind {
}
fn char_code_to_unicode(&self, code: u8) -> Option<char> {
// 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<char> {
// 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)
}
}
+5 -1
View File
@@ -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(),
+68 -175
View File
@@ -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<Key, IntVec>,
/// 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<u32>,
/// 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::<IntVec>();
let in_next = key.iter().map(|v| v.ceil() as u32).collect::<IntVec>();
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::<SmallVec<_>>();
self.clamper.clamp_output(&mut out);
@@ -157,95 +156,57 @@ type FloatVec = SmallVec<[f32; 4]>;
type IntVec = SmallVec<[u32; 4]>;
// See <https://github.com/apache/pdfbox/blob/bb778d4784f354c36ce032e91a0cee2169a4c598/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/common/function/PDFunctionType0.java#L252>
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<Key, IntVec>) -> Option<FloatVec> {
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<Key, IntVec>,
) -> Option<FloatVec> {
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<HashMap<Key, IntVec>> {
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(())
}
}
+14
View File
@@ -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<crate::color::Plate>,
/// 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<crate::color::InkSinkFn>,
}
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::<Array<'_>>(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::<Dict<'_>>() {
let flags = annot.get::<u32>(F).unwrap_or(0);
@@ -275,6 +288,7 @@ pub fn interpret_page<'a>(
draw_highlight_without_appearance(&annot, context, device);
}
}
device.end_annotations();
}
}
+56 -1
View File
@@ -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))
}
+24
View File
@@ -269,6 +269,12 @@ pub(crate) struct GraphicsState<'a> {
pub(crate) soft_mask: Option<SoftMask<'a>>,
pub(crate) transfer_function: Option<ActiveTransferFunction>,
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::<bool>(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::<bool>(key)?,
"OPM" => {
let mode = dict.get::<u8>(key)?;
context.get_mut().graphics_state.overprint_mode = u8::from(mode == 1);
}
"Type" => {}
_ => {}
}
+25 -1
View File
@@ -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")
+23 -21
View File
@@ -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<Self> {
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)));
}
}
}
+7 -1
View File
@@ -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,
+306 -28
View File
@@ -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<Dict<'a>>,
}
@@ -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::<Dict<'_>>(GROUP).is_some();
let group = dict.get::<Dict<'_>>(GROUP);
let is_transparency_group = group.is_some();
let is_isolated = group.as_ref().and_then(|g| g.get::<bool>(I)).unwrap_or(false);
let is_knockout = group.as_ref().and_then(|g| g.get::<bool>(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<ActiveTransferFunction>,
warning_sink: WarningSinkFn,
/// PdfCraft patch (35): the plate the image is drawn on, if any.
plate: Option<Plate>,
}
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<ColorSpace>,
@@ -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::<Stream<'_>>(SMASK).is_some_and(|s| uses_jpx(&s))
|| dict.get::<Stream<'_>>(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::<Name<'_>>(key).is_some_and(|name| is_jpx(&name))
|| dict
.get::<Array<'_>>(key)
.is_some_and(|filters| filters.iter::<Name<'_>>().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<Vec<f32>> {
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<RgbData> {
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<f32> = (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 {
+1 -1
View File
@@ -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": [
{
+22 -22
View File
@@ -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",
+13 -4
View File
@@ -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<Vec<u8>> {
/// 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<Vec<u8>> {
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<Vec<u8>> {
.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<Vec<u8>> {
}
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
+9 -1
View File
@@ -15,7 +15,15 @@ pub(crate) fn decode(
) -> Option<FilterResult<'static>> {
let k = params.get::<i32>(K).unwrap_or(0);
let rows = params.get::<u32>(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::<u32>(ROWS)
.filter(|&rows| rows != 0)
.map_or(image_params.height, |rows| rows.max(image_params.height));
let end_of_block = params.get::<bool>(END_OF_BLOCK).unwrap_or(true);
let settings = DecodeSettings {
+172 -2
View File
@@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
target_resolution: params.target_dimension,
};
let image = hayro_jpeg2000::Image::new(data, &settings).ok()?;
// PdfCraft patch (21): a JP2 colour specification naming an enumerated colour space the
// decoder refuses (CIEJab, enum 19, in a veraPDF fixture; YCbCr, PhotoYCC, …) made the whole
// image fail, so the page drew blank (#3306). Then it is decoded as if it had no colour
// specification, as MuPDF and Poppler draw it: grey, RGB or CMYK by its component count.
let repaired;
let image = match hayro_jpeg2000::Image::new(data, &settings) {
Ok(image) => 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<FilterRe
})
}
/// The enumerated colour spaces (`EnumCS`, ISO 15444-1 I.5.3.3 and 15444-2 M.11.7.2) the decoder
/// handles: CMYK, CIELab, sRGB, greyscale, sYCC, e-sRGB and ROMM-RGB.
const DECODED_ENUMERATED: [u32; 7] = [12, 14, 16, 17, 18, 20, 21];
/// PdfCraft patch (21): a copy of JP2 file `data` whose colour specification boxes (`colr`, in
/// the `jp2h` header box) that name an enumerated colour space outside [`DECODED_ENUMERATED`]
/// name the space the decoder assumes without one instead: greyscale, sRGB or CMYK for 1, 3 or
/// 4 components (`ihdr`). `None` when there is nothing to change or the boxes can't be read.
pub(crate) fn without_refused_enumerated_colour(data: &[u8]) -> Option<Vec<u8>> {
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<Item = ([u8; 4], usize, usize)> + '_ {
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<u8> {
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<u8> {
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<u32> {
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}");
}
}
}
+64 -14
View File
@@ -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(
+1 -1
View File
@@ -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)
+15 -8
View File
@@ -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<Stream<'a>> {
let length = dict.get::<u32>(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<Stream<'a>>
// 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")?;
+10 -2
View File
@@ -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,
)
}
}
+7 -7
View File
@@ -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"
}
]
}
+12 -4
View File
@@ -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(
+314 -40
View File
@@ -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<ColorReplacement>,
/// 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<bool>,
/// 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<LumaData>) {
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<BezPath> {
// 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::<Srgb>::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<SoftMask<'_>>,
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::<Srgb>::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<SoftMask<'_>>) {
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.