mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 20:42:12 +00:00
Merge craft-prepress into the print color PR
This commit is contained in:
commit
4026931196
50 files changed
+5208
-459
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"), "€");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Vendored
+52
-2
@@ -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.
|
||||
|
||||
Vendored
+47
-47
@@ -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": {
|
||||
|
||||
Vendored
+11
-1
@@ -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>())
|
||||
|
||||
Vendored
+1113
-32
File diff suppressed because it is too large.
Load diff
+6
@@ -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`].
|
||||
|
||||
Vendored
+41
@@ -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.
|
||||
|
||||
Vendored
+66
-37
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Vendored
+1
-1
@@ -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": [
|
||||
{
|
||||
|
||||
Vendored
+22
-22
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")?;
|
||||
|
||||
Vendored
+10
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
-7
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+12
-4
@@ -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(
|
||||
|
||||
Vendored
+314
-40
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user