From b6f60f25b19d10eefcf91d7a75369db1995eb452 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:30:13 +0000 Subject: [PATCH 01/11] vendor: re-sync hayro reader trees from pdfcraft ec0a965 Bring vendor/hayro, hayro-interpret, hayro-syntax and hayro-jbig2 to storytold/pdfcraft ec0a965074f376a5bf610435948e4e5184bf3299 (main, 2026-10-19), from 7437bde. The local patches (per-document decode limits, incomplete-content diagnostics, Hayro 0.8 resource scopes, nesting cap, iterative page tree, TJ fill order, transfer rounding, safety repairs) are carried by a three-way merge without conflicts. The re-sync includes transparency group /I and /K reading (pdfcraft #2027), patches 28, 29 and 31-34, and the reader fixes listed in vendor/README.craft-reader.md. SOURCE.json manifests, NOTICE and the craft-pdf README name the new source revision. --- NOTICE | 2 +- crates/pdf/README.md | 2 +- vendor/README.craft-reader.md | 30 ++- vendor/hayro-interpret/SOURCE.json | 86 +++---- vendor/hayro-interpret/src/cache.rs | 12 +- vendor/hayro-interpret/src/color.rs | 22 ++ vendor/hayro-interpret/src/context.rs | 6 + vendor/hayro-interpret/src/device.rs | 7 + vendor/hayro-interpret/src/encode.rs | 103 +++++--- vendor/hayro-interpret/src/font/mod.rs | 23 ++ .../hayro-interpret/src/font/standard_font.rs | 8 + vendor/hayro-interpret/src/font/true_type.rs | 6 + vendor/hayro-interpret/src/font/type1.rs | 16 ++ vendor/hayro-interpret/src/font/type3.rs | 6 +- vendor/hayro-interpret/src/function/type0.rs | 243 +++++------------- vendor/hayro-interpret/src/interpret/mod.rs | 4 + vendor/hayro-interpret/src/shading.rs | 34 +-- vendor/hayro-interpret/src/x_object.rs | 236 +++++++++++++++-- vendor/hayro-jbig2/SOURCE.json | 2 +- vendor/hayro-syntax/SOURCE.json | 44 ++-- vendor/hayro-syntax/src/filter/ascii_85.rs | 17 +- vendor/hayro-syntax/src/filter/ccitt.rs | 10 +- vendor/hayro-syntax/src/filter/jpx.rs | 174 ++++++++++++- vendor/hayro-syntax/src/filter/lzw_flate.rs | 78 +++++- vendor/hayro-syntax/src/filter/mod.rs | 2 +- vendor/hayro-syntax/src/object/stream.rs | 23 +- vendor/hayro-syntax/src/page.rs | 12 +- vendor/hayro/SOURCE.json | 14 +- vendor/hayro/src/lib.rs | 16 +- vendor/hayro/src/renderer.rs | 140 +++++++++- 30 files changed, 993 insertions(+), 385 deletions(-) diff --git a/NOTICE b/NOTICE index 9467671f..d9e06ba0 100644 --- a/NOTICE +++ b/NOTICE @@ -99,7 +99,7 @@ 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 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). diff --git a/crates/pdf/README.md b/crates/pdf/README.md index f35c5b1b..c728e716 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -56,7 +56,7 @@ Cancellation is polled between operators, so a decoder may finish before noticin ## Backend provenance and consumer pins The shared reader vendors the current patched Hayro trees from PdfCraft revision -`7437bde378f43856eada40af25b983812e615e8a`. See +`ec0a965074f376a5bf610435948e4e5184bf3299`. See [`vendor/README.craft-reader.md`](../../vendor/README.craft-reader.md) for the transfer and local patches. Lopdf, COS editing, inspection fallback and application caches remain app-owned. diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 704ce25d..dc956e6e 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -1,11 +1,37 @@ # 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-colour 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 colour 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. + The shared snapshot adds immutable per-document ordinary decode limits, threaded through initial xrefs, object streams, fallback and repair. Existing constructors retain their previous default. Known-size image budgets and streaming content budgets remain distinct. diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 27811044..2507cd07 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro-interpret", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro-interpret", "files": [ { @@ -174,21 +174,21 @@ }, { "path": "src/cache.rs", - "source_sha256": "a0a46ddbab7ed00b6bd0fb09f10d545c06bb4374ea0f520ae118c8d4f7b3c55d", - "source_git_blob": "2dda2e8a7cc0a2491b253ba1f42adac6074b1b34", - "sha256": "a0a46ddbab7ed00b6bd0fb09f10d545c06bb4374ea0f520ae118c8d4f7b3c55d" + "source_sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42", + "source_git_blob": "e253a7b8c7dbfbfa2f7dc1f6c582559962060b94", + "sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42" }, { "path": "src/color.rs", - "source_sha256": "c78cad4124e7bb203e0763f6bea36c542280fd0a8abfa3f7b3ea0bd728adb73b", - "source_git_blob": "dd2edf241d60f146ddb8ea4cbd4ad12a127fa0a7", - "sha256": "bc9b67008e8f7e5ba7bd96a9b49b6ec5d2818eedc44e003212342d8bc259cc36" + "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", + "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", + "sha256": "149d5a6b1b9e6212d2b3e9d63e1803a6041eb1798bfb9231ef89fc6bfcb7b968" }, { "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": "6f0c1203d1e60724cdd11e9a230249247a70f578c4a7ccb9bf39a23411b32db6" }, { "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,9 +360,9 @@ }, { "path": "src/interpret/mod.rs", - "source_sha256": "2b286df0c34b8194e1bdd1b63c1e08354d24f8389958fd782f42e2cabf364673", - "source_git_blob": "931b3388cef188cee0a5a1d495b69a6ff04f5e51", - "sha256": "6ae30906c8e6d0e869e0fac8ece4d97e0ac08d8abd7042c0242a069d4e609351" + "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", + "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", + "sha256": "1ed42a7777a6265f14efaaa5899dc96b93dc53aff85afacfcac60065e6e490d5" }, { "path": "src/interpret/path.rs", @@ -408,9 +408,9 @@ }, { "path": "src/shading.rs", - "source_sha256": "a28f730ede65bb4e480d0a6ae49a21e69ed23d7b0fd6f8006e487c83faa0756d", - "source_git_blob": "98fbb58310900160d1d4067e0707708ba2f673d0", - "sha256": "a28f730ede65bb4e480d0a6ae49a21e69ed23d7b0fd6f8006e487c83faa0756d" + "source_sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", + "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", + "sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32" }, { "path": "src/soft_mask.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": "48d296abf28696c4a3e01e9d12169c754062b0840c9f2b93051435247aba9144" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index 2dda2e8a..e253a7b8 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -72,7 +72,8 @@ pub(crate) struct DecodedImageCache { /// - the dictionary's bytes (`dict_hash`: `/Decode`, `/ColorSpace`, `/SMask`, `/Mask`, /// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it); /// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation, -/// the decode target size, and preview or final quality. +/// the decode target size (only for an image a JPX stream decodes, which is the only +/// decode that reads it: PdfCraft patch 32), and preview or final quality. /// /// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so /// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in @@ -95,6 +96,8 @@ pub(crate) struct DecodedImageKey { struct DecodedImageCacheInner { map: HashMap>>, bytes: usize, + /// PdfCraft patch (32): how many keyed decodes ran (cache misses). + decodes: usize, } impl DecodedImageCache { @@ -105,6 +108,12 @@ impl DecodedImageCache { } } + /// PdfCraft patch (32): how many decodes the cache was asked to keep (its misses), for + /// diagnostics and tests. 0 when the lock is poisoned. + pub(crate) fn decodes(&self) -> usize { + self.inner.lock().map(|locked| locked.decodes).unwrap_or(0) + } + /// The same cache, for a preview (`true`) or a final render. pub(crate) fn for_render(&self, preview: bool) -> Self { Self { @@ -141,6 +150,7 @@ impl DecodedImageCache { let Ok(mut locked) = self.inner.lock() else { return val; }; + locked.decodes = locked.decodes.saturating_add(1); // Another thread may have decoded it meanwhile: keep the first copy. if let Some(existing) = locked.map.get(&key) && let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::()) diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 4db84631..af359741 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -22,6 +22,25 @@ use std::sync::{Arc, LazyLock, OnceLock}; /// A storage for the components of colors. pub type ColorComponents = SmallVec<[f32; 4]>; +std::thread_local! { + // PdfCraft patch (34): see `tint_transform_runs`. + static TINT_TRANSFORM_RUNS: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN +/// tint transform, for diagnostics and tests. An image converted through patch (28)'s lookup +/// table runs it once per possible sample value, not once per pixel. Per thread, so a caller +/// counting its own render is not disturbed by renders on other threads. +pub fn tint_transform_runs() -> u64 { + TINT_TRANSFORM_RUNS.with(core::cell::Cell::get) +} + +/// PdfCraft patch (34): count `colours` runs of a tint transform (called once per batch). +fn count_tint_transform_runs(colours: usize) { + let colours = u64::try_from(colours).unwrap_or(u64::MAX); + TINT_TRANSFORM_RUNS.with(|runs| runs.set(runs.get().saturating_add(colours))); +} + /// An RGB color with an alpha channel. #[derive(Debug, Copy, Clone)] pub struct AlphaColor { @@ -857,6 +876,7 @@ impl Separation { impl ToRgb for Separation { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + count_tint_transform_runs(input.len()); let evaluated = input .iter() .flat_map(|n| { @@ -911,6 +931,8 @@ impl DeviceN { impl ToRgb for DeviceN { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + let colours = input.len().checked_div(usize::from(self.num_components)); + count_tint_transform_runs(colours.unwrap_or(0)); let evaluated = input .chunks_exact(self.num_components as usize) .flat_map(|n| { diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 9953b419..3614aee8 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -150,6 +150,12 @@ impl<'a> InterpreterCache<'a> { decoded_cache: DecodedImageCache::new(), } } + + /// PdfCraft patch (32): how many images were decoded through this cache (its misses, not + /// counting images that are never cached), for diagnostics and tests. + pub fn decoded_image_decodes(&self) -> usize { + self.decoded_cache.decodes() + } } /// A per-page interpretation context that borrows shared data from an [`InterpreterCache`]. diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 72d5a980..36c64a23 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -66,6 +66,13 @@ 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) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index 498ed909..b3f830ba 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -3,7 +3,7 @@ use crate::color::{AlphaColor, ColorComponents, ColorSpace}; use crate::interpret::state::ActiveTransferFunction; use crate::pattern::ShadingPattern; -use crate::shading::{ShadingFunction, ShadingType, Triangle}; +use crate::shading::{ShadingFunction, ShadingType, Triangle, TriangleVertex}; use kurbo::{Affine, Point}; use rustc_hash::FxHashMap; use smallvec::{ToSmallVec, smallvec}; @@ -102,7 +102,7 @@ impl ShadingPattern { } triangles.clear(); patch.to_triangles(&mut triangles); - if !sampler.sample(&triangles, full_transform) { + if !sampler.sample_owned(&mut triangles, full_transform) { break; } } @@ -126,7 +126,7 @@ impl ShadingPattern { } triangles.clear(); patch.to_triangles(&mut triangles); - if !sampler.sample(&triangles, full_transform) { + if !sampler.sample_owned(&mut triangles, full_transform) { break; } } @@ -268,47 +268,62 @@ impl TriangleSampler { /// Sample `triangles`; `false` once a budget is spent (the rest is left unsampled). fn sample(&mut self, triangles: &[Triangle], transform: Affine) -> bool { + if !self.take_page_budget(triangles.len()) { + return false; + } + triangles.iter().all(|t| { + self.sample_one(transformed( + t.p0.clone(), + t.p1.clone(), + t.p2.clone(), + transform, + )) + }) + } + + /// PdfCraft patch (33): [`Self::sample`] for triangles made for this sampling alone (a + /// patch's), moving their vertices instead of copying each one's colours (`triangles` is + /// left empty). + fn sample_owned(&mut self, triangles: &mut Vec, transform: Affine) -> bool { + if !self.take_page_budget(triangles.len()) { + return false; + } + triangles + .drain(..) + .all(|t| self.sample_one(transformed(t.p0, t.p1, t.p2, transform))) + } + + /// Take `count` triangles from the page's budget; `false` (and none left) when it is short. + fn take_page_budget(&mut self, count: usize) -> bool { let page_left = MESH_TRIANGLES_LEFT.with(core::cell::Cell::get); - if page_left < triangles.len() as u64 { + if page_left < count as u64 { MESH_TRIANGLES_LEFT.with(|n| n.set(0)); warn!("page mesh triangle budget exceeded; the rest of the shading is not drawn"); return false; } - MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - triangles.len() as u64)); - for t in triangles { - let t = { - let p0 = transform * t.p0.point; - let p1 = transform * t.p1.point; - let p2 = transform * t.p2.point; + MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - count as u64)); + true + } - let mut v0 = t.p0.clone(); - v0.point = p0; - let mut v1 = t.p1.clone(); - v1.point = p1; - let mut v2 = t.p2.clone(); - v2.point = p2; + /// Sample one triangle in device space; `false` once the visit budget is spent. + fn sample_one(&mut self, t: Triangle) -> bool { + // Only the part of the triangle inside `area`; only such triangles add visits. + let bbox = t.bounding_box().intersect(self.area); + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return true; + } + self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); - Triangle::new(v0, v1, v2) - }; - - // Only the part of the triangle inside `area`; only such triangles add visits. - let bbox = t.bounding_box().intersect(self.area); - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { - continue; - } - self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); - - for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { - for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { - if self.visits_left == 0 { - warn!("mesh shading visit budget exceeded; the rest is not drawn"); - return false; - } - self.visits_left -= 1; - let point = Point::new(x as f64, y as f64); - if t.contains_point(point) { - self.map.insert((x, y), t.interpolate(point)); - } + for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { + for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { + if self.visits_left == 0 { + warn!("mesh shading visit budget exceeded; the rest is not drawn"); + return false; + } + self.visits_left -= 1; + let point = Point::new(x as f64, y as f64); + if t.contains_point(point) { + self.map.insert((x, y), t.interpolate(point)); } } } @@ -316,6 +331,20 @@ impl TriangleSampler { } } +/// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as +/// they are). +fn transformed( + mut v0: TriangleVertex, + mut v1: TriangleVertex, + mut v2: TriangleVertex, + transform: Affine, +) -> Triangle { + v0.point = transform * v0.point; + v1.point = transform * v1.point; + v2.point = transform * v2.point; + Triangle::new(v0, v1, v2) +} + fn encode_function_shading(domain: &[f32; 4], function: &ShadingFunction) -> EncodedShadingType { let domain = kurbo::Rect::new( domain[0] as f64, diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 5ccf5c48..7c8c8d08 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -444,6 +444,29 @@ impl Encoding { Self::BuiltIn => None, } } + + /// PdfCraft patch: the glyph name an Annex D.2 *alias code* reads as when the glyph is taken + /// as text rather than looked up in a font program; `None` for every other code. + /// + /// Annex D.2 gives WinAnsiEncoding codes 0xA0 and 0xAD the glyph names `space` and `hyphen`, + /// the same names it gives 0x20 and 0x2D, and MacRomanEncoding 0xCA `space`. The tables above + /// call them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are + /// *formatting* characters, so extraction hid ink the reader can see: a soft hyphen is + /// invisible, and text that drew one matched no search and no redaction pattern -- one corpus + /// file alone produced 104 of them -- while a non-breaking space did not match the space a + /// reader typed. pdftotext reads both codes as the ordinary character. + /// + /// Only the base table is aliased, in the text direction only. A `/Differences` entry naming + /// `sfthyphen` or `nbspace` is a deliberate statement and keeps its glyph list value, which + /// is what pdftotext does too; and glyph *lookup* is untouched, so a font program's own + /// `sfthyphen` program is still found first. + fn text_alias(&self, code: u8) -> Option<&'static str> { + match (self, code) { + (Self::WinAnsi, 0xA0) | (Self::MacRoman, 0xCA) => Some("space"), + (Self::WinAnsi, 0xAD) => Some("hyphen"), + _ => None, + } + } } /// The font stretch. diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index c73c75dd..139454a0 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -546,6 +546,14 @@ impl StandardKind { } pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 40f33590..b3626698 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -286,7 +286,13 @@ impl EmbeddedKind { self.differences .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // The last resort reads the code as WinAnsi whatever the font said, so it takes the + // same aliasing. + .or_else(|| Encoding::WinAnsi.text_alias(code)) .or_else(|| win_ansi::get(code)) } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 32b5e3dd..02f76bfb 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -239,6 +239,14 @@ impl Type1Kind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } @@ -336,6 +344,14 @@ impl CffKind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index 6824d4ff..216f2618 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -108,6 +108,9 @@ impl<'a> Type3<'a> { .encodings .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; + // see `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)); name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) @@ -165,9 +168,10 @@ impl<'a> Type3<'a> { is_shape_glyph }; + // PdfCraft patch: context clips and shading bounds are in device space; FontBBox is in glyph space. let mut context = Context::new_with( state.ctm, - self.font_bbox, + root_transform.transform_rect_bbox(self.font_bbox), &glyph.cache, glyph.xref, glyph.settings.clone(), diff --git a/vendor/hayro-interpret/src/function/type0.rs b/vendor/hayro-interpret/src/function/type0.rs index 02bbc41b..9a82c597 100644 --- a/vendor/hayro-interpret/src/function/type0.rs +++ b/vendor/hayro-interpret/src/function/type0.rs @@ -3,14 +3,18 @@ use hayro_syntax::bit_reader::BitReader; use hayro_syntax::object::Array; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::{BITS_PER_SAMPLE, DECODE, ENCODE, SIZE}; -use smallvec::{SmallVec, ToSmallVec, smallvec}; -use std::collections::HashMap; +use smallvec::SmallVec; /// A type 0 function (sampled function). #[derive(Debug)] pub(crate) struct Type0 { sizes: IntVec, - table: HashMap, + /// PdfCraft patch (31): the samples in stream order, `range.len()` to a grid point, the + /// first input varying fastest. They were a hash map keyed by each grid point, built again + /// every time a shading using the function was painted. + samples: Vec, + /// PdfCraft patch (31): how many grid points one step along each input moves. + strides: SmallVec<[usize; 4]>, clamper: Clamper, range: TupleVec, bits_per_sample: u8, @@ -85,14 +89,22 @@ impl Type0 { data.truncate(num_expected_entries); } - let table = build_table(&data, &sizes, range.len())?; + // Saturating: the grid has at most `MAX_SAMPLE_ENTRIES` points unless a size is 0, and + // then no point is on it (`entry` checks each coordinate against its size first). + let mut strides = SmallVec::with_capacity(sizes.len()); + let mut stride = 1usize; + for size in &sizes { + strides.push(stride); + stride = stride.saturating_mul(*size as usize); + } Some(Self { sizes, clamper, range, bits_per_sample, - table, + samples: data, + strides, encode, decode, }) @@ -122,29 +134,16 @@ impl Type0 { let in_prev = key.iter().map(|v| v.floor() as u32).collect::(); let in_next = key.iter().map(|v| v.ceil() as u32).collect::(); + let mut coord: IntVec = in_prev.clone(); - let interpolator = Interpolator::new( - key.clone().to_smallvec(), - in_prev, - in_next, - self.sizes.clone(), - self.range.len(), - ); - - let interpolated = interpolator.interpolate(&self.table)?; + let interpolated = self.interpolate(&key, &in_prev, &in_next, &mut coord, 0)?; + // PdfCraft patch (31): `2_u32.pow(32)` overflowed (a panic with overflow checks on). + let max_sample = ((1u64 << self.bits_per_sample) - 1) as f32; let mut out = interpolated .iter() .zip(self.decode.iter()) - .map(|(x, decode)| { - interpolate( - *x, - 0.0, - (2_u32.pow(self.bits_per_sample as u32) - 1) as f32, - decode.0, - decode.1, - ) - }) + .map(|(x, decode)| interpolate(*x, 0.0, max_sample, decode.0, decode.1)) .collect::>(); self.clamper.clamp_output(&mut out); @@ -157,95 +156,57 @@ type FloatVec = SmallVec<[f32; 4]>; type IntVec = SmallVec<[u32; 4]>; // See -struct Interpolator { - input: FloatVec, - sizes: IntVec, - in_prev: IntVec, - in_next: IntVec, - out_len: usize, -} - -impl Interpolator { - fn new( - input: FloatVec, - in_prev: IntVec, - in_next: IntVec, - sizes: IntVec, - out_len: usize, - ) -> Self { - Self { - input, - in_prev, - in_next, - sizes, - out_len, - } - } - - fn interpolate(&self, table: &HashMap) -> Option { - self.interpolate_inner(smallvec![0; self.input.len()], 0, table) - } - - fn interpolate_inner( +impl Type0 { + /// PdfCraft patch (31): the value at `input` between the grid points `in_prev` and + /// `in_next`, interpolating the inputs from `step` on (the earlier ones are placed in + /// `coord`). The same multilinear interpolation, in the same order, as the hash-map + /// version, so the results are bit for bit the same; one level of recursion per input, + /// as before. + fn interpolate( &self, - mut coord: IntVec, + input: &[f32], + in_prev: &[u32], + in_next: &[u32], + coord: &mut IntVec, step: usize, - table: &HashMap, ) -> Option { - if step == self.input.len() - 1 { - if self.in_prev[step] == self.in_next[step] { - coord[step] = self.in_prev[step]; + let (Some(x), Some(prev), Some(next)) = + (input.get(step), in_prev.get(step), in_next.get(step)) + else { + // Every input placed: the grid point itself. + return Some(self.entry(coord)?.iter().map(|n| *n as f32).collect()); + }; + let (x, prev, next) = (*x, *prev, *next); - Some( - table - .get(&Key::from_raw(&self.sizes, &coord))? - .clone() - .iter() - .map(|n| *n as f32) - .collect(), - ) - } else { - coord[step] = self.in_prev[step]; - let val1 = table.get(&Key::from_raw(&self.sizes, &coord))?; - coord[step] = self.in_next[step]; - let val2 = table.get(&Key::from_raw(&self.sizes, &coord))?; - let mut out = smallvec![0.0; self.out_len]; - - for i in 0..self.out_len { - out[i] = interpolate( - self.input[step], - self.in_prev[step] as f32, - self.in_next[step] as f32, - val1[i] as f32, - val2[i] as f32, - ); - } - - Some(out) - } - } else if self.in_prev[step] == self.in_next[step] { - coord[step] = self.in_prev[step]; - self.interpolate_inner(coord, step + 1, table) - } else { - coord[step] = self.in_prev[step]; - let val1 = self.interpolate_inner(coord.clone(), step + 1, table)?; - coord[step] = self.in_next[step]; - let val2 = self.interpolate_inner(coord, step + 1, table)?; - - let mut out = smallvec![0.0; self.out_len]; - - for i in 0..self.out_len { - out[i] = interpolate( - self.input[step], - self.in_prev[step] as f32, - self.in_next[step] as f32, - val1[i], - val2[i], - ); - } - - Some(out) + *coord.get_mut(step)? = prev; + let val1 = self.interpolate(input, in_prev, in_next, coord, step + 1)?; + if prev == next { + return Some(val1); } + *coord.get_mut(step)? = next; + let val2 = self.interpolate(input, in_prev, in_next, coord, step + 1)?; + + Some( + val1.iter() + .zip(val2.iter()) + .map(|(a, b)| interpolate(x, prev as f32, next as f32, *a, *b)) + .collect(), + ) + } + + /// PdfCraft patch (31): the samples of grid point `coord`, or `None` where it is off the + /// grid or the stream ended before it (where the hash map had no entry). + fn entry(&self, coord: &[u32]) -> Option<&[u32]> { + let mut index = 0usize; + for ((c, size), stride) in coord.iter().zip(&self.sizes).zip(&self.strides) { + if c >= size { + return None; + } + index = index.checked_add((*c as usize).checked_mul(*stride)?)?; + } + let n = self.range.len(); + let start = index.checked_mul(n)?; + self.samples.get(start..start.checked_add(n)?) } } @@ -257,71 +218,3 @@ impl Interpolator { /// values, 17^5 points with 4 outputs 5.7 million values. const MAX_SAMPLE_VALUES: usize = 1 << 23; const MAX_SAMPLE_ENTRIES: usize = 1 << 21; - -fn build_table(data: &[u32], sizes: &[u32], n: usize) -> Option> { - let mut key = Key::new(sizes); - let mut table = HashMap::new(); - - let mut first = true; - - for b in data.chunks_exact(n) { - if !first { - key.increment(); - } - - table.insert(key.clone(), b.to_smallvec()); - - first = false; - } - - Some(table) -} - -/// A sampled function consists of a (possibly) multi-dimensional table that we can index -/// into. We do this by representing the entries as a flat list of vectors, where each -/// element in the vector represents the value of the key in that specific dimension. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct Key { - sizes: SmallVec<[u32; 4]>, - parts: SmallVec<[u32; 4]>, -} - -impl Key { - fn new(sizes: &[u32]) -> Self { - let parts = smallvec![0; sizes.len()]; - - Self { - sizes: sizes.to_smallvec(), - parts, - } - } - - fn from_raw(sizes: &[u32], parts: &[u32]) -> Self { - Self { - sizes: sizes.to_smallvec(), - parts: parts.to_smallvec(), - } - } - - fn increment(&mut self) -> Option<()> { - self.increment_index(0) - } - - fn increment_index(&mut self, index: usize) -> Option<()> { - let size = *self.sizes.get(index).or_else(|| { - error!("overflowed key in sampled function"); - - None - })?; - let val = self.parts.get_mut(index)?; - - if *val >= (size - 1) { - *val = 0; - self.increment_index(index + 1)?; - } else { - *val += 1; - } - - Some(()) - } -} diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index a0c6308e..b389b6b3 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -175,6 +175,9 @@ pub fn interpret_page<'a>( if context.settings.render_annotations && let Some(annot_arr) = page.raw().get::>(ANNOTS) { + // PdfCraft patch (29): bracket the annotations so devices can tell their paints from + // the document's own (the reading-colours setting recolours the document, not comments). + device.begin_annotations(); for annot in annot_arr.iter::>() { let flags = annot.get::(F).unwrap_or(0); @@ -275,6 +278,7 @@ pub fn interpret_page<'a>( draw_highlight_without_appearance(&annot, context, device); } } + device.end_annotations(); } } diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 98fbb583..2e619e47 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -731,6 +731,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 +745,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 +756,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 +794,9 @@ where triangle }; - buffer.push(inflate(Triangle::new( - v00.clone(), - v10.clone(), - v01.clone(), - ))); - buffer.push(inflate(Triangle::new( - v10.clone(), - v11.clone(), - v01.clone(), - ))); + // PdfCraft patch (33): the second triangle takes the vertices the first copied. + buffer.push(inflate(Triangle::new(v00, v10.clone(), v01.clone()))); + buffer.push(inflate(Triangle::new(v10, v11, v01))); } } } diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 767a54f3..d7d19dce 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -60,6 +60,9 @@ pub(crate) struct FormXObject<'a> { pub(crate) matrix: Affine, pub(crate) bbox: [f32; 4], is_transparency_group: bool, + // PdfCraft patch: the group's /I and /K (ISO 32000-2 §11.6.6, both false by default). + is_isolated: bool, + is_knockout: bool, pub(crate) dict: Dict<'a>, resources: Option>, } @@ -77,12 +80,17 @@ impl<'a> FormXObject<'a> { .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), ); let bbox = dict.get::<[f32; 4]>(BBOX)?; - let is_transparency_group = dict.get::>(GROUP).is_some(); + let group = dict.get::>(GROUP); + let is_transparency_group = group.is_some(); + let is_isolated = group.as_ref().and_then(|g| g.get::(I)).unwrap_or(false); + let is_knockout = group.as_ref().and_then(|g| g.get::(K)).unwrap_or(false); Some(Self { decoded, matrix, is_transparency_group, + is_isolated, + is_knockout, bbox, dict: dict.clone(), resources, @@ -134,7 +142,26 @@ 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 + }; + let own_layer = x_object.is_transparency_group && !neutral; + + 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), @@ -169,7 +196,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(); } @@ -407,11 +434,28 @@ impl<'a> ImageXObject<'a> { is_mask: self.is_mask, is_stencil_mask: self.is_stencil_mask, interpolate: self.interpolate, - target: target_dimension, + // PdfCraft patch (32): see `decode_reads_target`. + target: if self.decode_reads_target() { + target_dimension + } else { + None + }, preview: cache.preview, }) } + /// PdfCraft patch (32): whether decoding this image depends on the size it is drawn at. + /// Only the JPX filter decodes at a reduced resolution for a target size, in the image + /// itself or in the soft mask or mask decoded with it; every other filter decodes the whole + /// image whatever the target. Keyed by the target, a scan was decoded again at every zoom + /// step and kept once per zoom level; without it, one decode serves them all. + fn decode_reads_target(&self) -> bool { + let dict = self.stream.dict(); + uses_jpx(&self.stream) + || dict.get::>(SMASK).is_some_and(|s| uses_jpx(&s)) + || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s)) + } + pub(crate) fn width(&self) -> u32 { self.width } @@ -440,6 +484,20 @@ fn decoded_mask_size(mask: &DecodedMask) -> usize { mask.luma.width as usize * mask.luma.height as usize } +/// PdfCraft patch (32): whether `stream` names the JPX filter, as a name or in an array, under +/// `/Filter` or its inline abbreviation `/F`. Errs towards `true`: a name the decoder would +/// ignore still counts, which only keeps the target in the key as before. +fn uses_jpx(stream: &Stream<'_>) -> bool { + let dict = stream.dict(); + let is_jpx = |name: &Name<'_>| name.deref() == JPX_DECODE; + [F, FILTER].into_iter().any(|key| { + dict.get::>(key).is_some_and(|name| is_jpx(&name)) + || dict + .get::>(key) + .is_some_and(|filters| filters.iter::>().any(|name| is_jpx(&name))) + }) +} + /// PdfCraft patch: pixel bytes held by a decoded raster image and its mask. fn decoded_raster_size(raster: &DecodedRaster) -> usize { let image = match &raster.image { @@ -667,23 +725,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 +1107,136 @@ fn apply_decode_array( bits_per_component: u8, decode: &[(f32, f32)], ) -> Option> { - let interpolate = |n: f32, d_min: f32, d_max: f32| { - interpolate( - n, - 0.0, - 2.0_f32.powi(bits_per_component as i32) - 1.0, - d_min, - d_max, - ) - }; - let mut decoded_arr = vec![]; for pixel in components.chunks(color_space.num_components() as usize) { - for (component, (d_min, d_max)) in pixel.iter().zip(decode) { - decoded_arr.push(interpolate(*component as f32, *d_min, *d_max)); + for (component, range) in pixel.iter().zip(decode) { + decoded_arr.push(decode_sample(*component, bits_per_component, *range)); } } Some(decoded_arr) } +/// One sample mapped through its `/Decode` range (ISO 32000-2 §8.9.5.2). PdfCraft patch (28): +/// shared by [`apply_decode_array`] and [`rgb_via_lookup`], so both decode alike. +fn decode_sample(sample: u16, bits_per_component: u8, (d_min, d_max): (f32, f32)) -> f32 { + interpolate( + sample as f32, + 0.0, + 2.0_f32.powi(bits_per_component as i32) - 1.0, + d_min, + d_max, + ) +} + +/// PdfCraft patch (28): the most bits a pixel's samples may hold together for +/// [`rgb_via_lookup`] (a table of 65,536 colours). +const MAX_LOOKUP_BITS: usize = 16; + +/// PdfCraft patch (28): the RGB of an image whose pixels hold at most [`MAX_LOOKUP_BITS`] bits +/// of samples, converted once per possible pixel value rather than once per pixel, at the +/// height `fix_image_length` gives. `None` when the table would not pay off (the image has no +/// more pixels than it has entries) or does not apply; the caller then converts every pixel. +/// +/// The colour of a pixel depends only on its own samples, so the result is the per-pixel one, +/// except where a tint transform gives the wrong number of components. The per-pixel batch then +/// shifts the colours after that pixel. The table converts each entry on its own +/// ([`convert_each`]), so a wrong-sized entry changes only itself, and a value the image never +/// uses cannot change the colour of one it does. +/// A Separation or DeviceN tint transform is a PDF function, often a PostScript calculator +/// program: run once per pixel, an 8-bit spot-colour background of 3304 × 4105 took four +/// seconds and 430 MB to draw; looked up from 256 entries it takes milliseconds. +#[allow(clippy::too_many_arguments)] +fn rgb_via_lookup( + components: &[u16], + width: u32, + height: u32, + scale_factors: (f32, f32), + cs: &ColorSpace, + bits_per_component: u8, + decode: &[(f32, f32)], + interpolate: bool, +) -> Option { + let n = cs.num_components() as usize; + let bpc = usize::from(bits_per_component); + let bits = bpc.checked_mul(n)?; + // `apply_decode_array` decodes only the samples `/Decode` has a range for: leave a short + // array to it, and every sample size it doesn't read. + if n == 0 || bits == 0 || bits > MAX_LOOKUP_BITS || bpc > 16 || decode.len() < n { + return None; + } + // The length rules of `fix_image_length`: extra samples are dropped; missing ones shorten + // the image to the rows begun, the last row padded with decoded zeros. + let row_len = usize::try_from(width).ok()?.checked_mul(n)?; + let wanted = row_len.checked_mul(usize::try_from(height).ok()?)?; + let (height, used) = if wanted <= components.len() { + (height, wanted) + } else { + (u32::try_from(components.len().div_ceil(row_len)).ok()?, components.len()) + }; + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + let total = pixels.checked_mul(n)?; + let entries = 1usize << bits; + if pixels <= entries { + return None; + } + + // Every possible pixel value, its samples in order, decoded as `apply_decode_array` would. + let mask = (1usize << bpc) - 1; + let mut table_input = Vec::with_capacity(entries * n); + for code in 0..entries { + for (k, range) in decode.iter().take(n).enumerate() { + let sample = ((code >> (bpc * (n - 1 - k))) & mask) as u16; + table_input.push(decode_sample(sample, bits_per_component, *range)); + } + } + let mut table = vec![0; entries * 3]; + convert_each(cs, &table_input, n, &mut table); + + let mut data = vec![0; pixels.checked_mul(3)?]; + let whole = used / n; + let samples = components.get(..whole * n)?; + for (pixel, rgb) in samples.chunks_exact(n).zip(data.chunks_exact_mut(3)) { + let code = pixel.iter().fold(0usize, |code, s| (code << bpc) | (usize::from(*s) & mask)); + if let Some(colour) = table.get(code * 3..code * 3 + 3) { + rgb.copy_from_slice(colour); + } + } + // The pixels past the samples present (at most a row): converted as before, padding and all. + if whole < pixels { + let tail: Vec = (whole * n..total) + .map(|i| match (components.get(i), decode.get(i % n)) { + (Some(s), Some(range)) if i < used => decode_sample(*s, bits_per_component, *range), + _ => 0.0, + }) + .collect(); + let _ = cs.convert_f32(&tail, data.get_mut(whole * 3..)?, false); + } + + Some(RgbData { + data, + width, + height, + interpolate, + scale_factors, + }) +} + +/// PdfCraft patch (28): `input`, `n` decoded samples a pixel, converted into `output` (RGB) one +/// pixel at a time. A Separation or DeviceN tint transform may give more or fewer components +/// than its alternate space takes (a Type 4 program's count is only warned about): converted as +/// one batch, such a result shifts every pixel after it; converted alone, it affects only its +/// own. A failed conversion leaves black, as in `get_rgb_data`. +fn convert_each(cs: &ColorSpace, input: &[f32], n: usize, output: &mut [u8]) { + if n == 0 { + return; + } + for (pixel, rgb) in input.chunks_exact(n).zip(output.chunks_exact_mut(3)) { + let _ = cs.convert_f32(pixel, rgb, false); + } +} + /// PdfCraft patch: whether an image or mask of `width` × `height` may be decoded (at most /// 2^28 pixels, the renderer's `MAX_IMAGE_PIXELS`). pub fn image_size_ok(width: u32, height: u32) -> bool { diff --git a/vendor/hayro-jbig2/SOURCE.json b/vendor/hayro-jbig2/SOURCE.json index 50ec8937..bf51f3bb 100644 --- a/vendor/hayro-jbig2/SOURCE.json +++ b/vendor/hayro-jbig2/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro-jbig2", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro-jbig2", "files": [ { diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index 721bac61..6419aee4 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro-syntax", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro-syntax", "files": [ { @@ -132,9 +132,9 @@ }, { "path": "src/filter/ascii_85.rs", - "source_sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24", - "source_git_blob": "5a18cf8352d7d5a1770b4ea3068254187ff4b4f9", - "sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24" + "source_sha256": "550cd5b3ceb7ee9342959ac9cc80c35b176aed3c7f061789e379beb8b5873f48", + "source_git_blob": "943bfeb742ac8cdc8ba64fcc538a044cb812577e", + "sha256": "550cd5b3ceb7ee9342959ac9cc80c35b176aed3c7f061789e379beb8b5873f48" }, { "path": "src/filter/ascii_hex.rs", @@ -144,9 +144,9 @@ }, { "path": "src/filter/ccitt.rs", - "source_sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf", - "source_git_blob": "98924f2dbdd942bc6ee64f66749b8d2496d8b45e", - "sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf" + "source_sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8", + "source_git_blob": "685d680d2fbadce608a9bd3ca925c2ccc6c5d641", + "sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8" }, { "path": "src/filter/dct.rs", @@ -162,21 +162,21 @@ }, { "path": "src/filter/jpx.rs", - "source_sha256": "a9cab59f286bf3d3c99aa008f8fdede5028117c0f36018f3c02e52ed097c299a", - "source_git_blob": "6a788591e3338c55eccd616e21aa6e683f8d3351", - "sha256": "a9cab59f286bf3d3c99aa008f8fdede5028117c0f36018f3c02e52ed097c299a" + "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", + "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", + "sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82" }, { "path": "src/filter/lzw_flate.rs", - "source_sha256": "e2d2196bc47c00474dc8f61d281ba327b88aa0d5f9e5475d29bf63a0572522e3", - "source_git_blob": "f9da1590b27b3e582ddac2fdfe9c1ef80ea72aff", - "sha256": "e1a6125b27423c91d79127ce6f8bfec507bd4d6ab0f959b2740a8a6e392d3e49" + "source_sha256": "db51a57effe90ad9db2d314e16081ce90e3ef99e5a5f46fcc4bdbdacfd13a528", + "source_git_blob": "f1ab1aae73e4c84ec0993a6eb96594018ca7cc91", + "sha256": "00d478a55b0c123d903768186592d138d563543abf46e5c786c67b9fffd69637" }, { "path": "src/filter/mod.rs", - "source_sha256": "5e6d62da5b1fd023f85f79bc8ade928b508214e13668f9fc99f3a08bc4d48a15", - "source_git_blob": "22bebf6d3287f1dc893c67b76a139108b28a8188", - "sha256": "8cc6c2572519046bd27adfc597630045e453829a954eaf1747fee7a447e5c5cb" + "source_sha256": "0af7145ab16b15b7a60c4b57efecb9bd5fd0cfdc9bb5f9388abb695386e63e49", + "source_git_blob": "06bda29487d62c25dc39c1ee109091cb17e3867f", + "sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9" }, { "path": "src/filter/run_length.rs", @@ -276,9 +276,9 @@ }, { "path": "src/object/stream.rs", - "source_sha256": "151721c7bfe558a6942aba323d3185cf6db5bf726bd905326881c70ef480240f", - "source_git_blob": "833610817b4738304a7376cd43ea079cb352bd8c", - "sha256": "e83d7ecb01a038d5a0ae589d77e877f9521097010e5f88db8f8a333fd161aff1" + "source_sha256": "f5229eb2f52c9918830027c93112e042a9e97d34b5fec72f97dcc7de8c5410ac", + "source_git_blob": "a80f0730cba317ed3771cee61d6a7ab18de296d7", + "sha256": "6cd27e9952715244a35d3f2ea610f401ae75fe60cf9740b69257a7ca704cce0b" }, { "path": "src/object/string.rs", @@ -294,9 +294,9 @@ }, { "path": "src/page.rs", - "source_sha256": "19d0e7604a3a692956e523128022d07944253b308793598795e09595cb04e79e", - "source_git_blob": "7d63931a324f5faf90bb37e0a90670643567dace", - "sha256": "681681016eda0d1cdbec10d2cb0305c5f439724ec5d0b3be218a18e99b5e72b8" + "source_sha256": "17be32b2eaa03a3367f1f20040556f7ee5b207588cf44a1af3e5253386fb46f9", + "source_git_blob": "d7f0ec953722a938508f1f31f0adaecc0a181d20", + "sha256": "8993224b8f403c95c32803113139fe4c8fc25338571466b37bad0874b21f8145" }, { "path": "src/pdf.rs", diff --git a/vendor/hayro-syntax/src/filter/ascii_85.rs b/vendor/hayro-syntax/src/filter/ascii_85.rs index 5a18cf83..943bfeb7 100644 --- a/vendor/hayro-syntax/src/filter/ascii_85.rs +++ b/vendor/hayro-syntax/src/filter/ascii_85.rs @@ -4,7 +4,9 @@ use crate::reader::Reader; use crate::trivia::is_white_space_character; use alloc::vec::Vec; -pub(crate) fn decode(data: &[u8]) -> Option> { +/// PdfCraft patch: `limit` is the most bytes produced (see `filter::decode_limit`); each `z` +/// expands one input byte to four. +pub(crate) fn decode(data: &[u8], limit: usize) -> Option> { const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; let mut reader = Reader::new(data); @@ -50,15 +52,21 @@ pub(crate) fn decode(data: &[u8]) -> Option> { .checked_add(digits[3].checked_mul(POW_85[3])?)? .checked_add(digits[4])?; - decoded.extend_from_slice(&value.to_be_bytes()[..output_len]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend_from_slice(&value.to_be_bytes()[..output_len.min(left)]); group.clear(); Some(()) }; - let mut decoded = Vec::with_capacity(data.len() * 4 / 5); + let mut decoded = Vec::with_capacity((data.len() / 5 * 4).min(limit)); let mut group = Vec::with_capacity(5); loop { + // PdfCraft patch: output cut at the limit is reported, never dropped silently. + if decoded.len() >= limit { + warn!("ASCII85 stream stopped at its decode limit of {limit} bytes"); + return Some(decoded); + } let Some(b) = read_byte() else { // Be lenient and accept what we have (see PDFBOX-5910). flush_group(&mut group, &mut decoded)?; @@ -76,7 +84,8 @@ pub(crate) fn decode(data: &[u8]) -> Option> { } b'z' => { flush_group(&mut group, &mut decoded)?; - decoded.extend_from_slice(&[0, 0, 0, 0]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend(core::iter::repeat_n(0, left.min(4))); } b'~' => { // Technically requires a '>', but there is a PDF where it isn't diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 98924f2d..685d680d 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -15,7 +15,15 @@ pub(crate) fn decode( ) -> Option> { let k = params.get::(K).unwrap_or(0); - let rows = params.get::(ROWS).unwrap_or(image_params.height); + // PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and + // the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank. + // PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a + // plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black + // (#3308). The decoder still stops where the data ends. + let rows = params + .get::(ROWS) + .filter(|&rows| rows != 0) + .map_or(image_params.height, |rows| rows.max(image_params.height)); let end_of_block = params.get::(END_OF_BLOCK).unwrap_or(true); let settings = DecodeSettings { diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 6a788591..74efd8b3 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option image, + Err(_) => { + repaired = without_refused_enumerated_colour(data)?; + hayro_jpeg2000::Image::new(&repaired, &settings).ok()? + } + }; let width = image.width(); let height = image.height(); @@ -87,6 +98,82 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option Option> { + let header = boxes(data).find(|&(kind, _, _)| kind == *b"jp2h")?; + let (_, start, end) = header; + let inner = data.get(start..end)?; + let (_, ih_start, _) = boxes(inner).find(|&(kind, _, _)| kind == *b"ihdr")?; + // ihdr: height (4), width (4), number of components (2). + let components = u16::from_be_bytes(inner.get(ih_start + 8..ih_start + 10)?.try_into().ok()?); + let replacement: u32 = match components { + 1 => 17, + 3 => 16, + 4 => 12, + _ => return None, + }; + // Where each refused enum is, found before anything is copied: a stream the decoder refused + // for another reason isn't copied for nothing. + let mut refused = Vec::new(); + for (kind, body, body_end) in boxes(inner) { + if kind != *b"colr" { + continue; + } + // colr: METH (1), PREC (1), APPROX (1), then EnumCS (4) when METH is 1. + let colr = inner.get(body..body_end)?; + if colr.first() != Some(&1) { + continue; + } + let enumerated = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?); + if DECODED_ENUMERATED.contains(&enumerated) { + continue; + } + refused.push(start.checked_add(body)?.checked_add(3)?); + } + if refused.is_empty() { + return None; + } + let mut out = data.to_vec(); + for at in refused { + out.get_mut(at..at.checked_add(4)?)?.copy_from_slice(&replacement.to_be_bytes()); + } + Some(out) +} + +/// The boxes in `data` (ISO 15444-1 I.4): each one's type and the start and end of its contents. +/// Stops at the first box whose length doesn't fit. +fn boxes(data: &[u8]) -> impl Iterator + '_ { + let mut at = 0usize; + core::iter::from_fn(move || { + let length = u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?); + let kind: [u8; 4] = data.get(at + 4..at.checked_add(8)?)?.try_into().ok()?; + let (header, size) = match length { + // The rest of the data. + 0 => (8, data.len().checked_sub(at)?), + // An 8-byte length follows the type. + 1 => { + let large = u64::from_be_bytes(data.get(at + 8..at.checked_add(16)?)?.try_into().ok()?); + (16, usize::try_from(large).ok()?) + } + n => (8, usize::try_from(n).ok()?), + }; + let end = at.checked_add(size)?; + if size < header || end > data.len() { + return None; + } + let item = (kind, at + header, end); + at = end; + Some(item) + }) +} + fn scale( data: &[u8], bit_per_component: u8, @@ -134,7 +221,7 @@ fn scale( #[cfg(test)] mod tests { - use super::scale; + use super::{scale, without_refused_enumerated_colour}; use crate::bit_reader::{BitReader, bit_mask}; use alloc::vec::Vec; @@ -188,4 +275,87 @@ mod tests { } } } + + /// A JP2 header (`jp2h`) holding `ihdr` for `components` and the given `colr` bodies, with + /// a codestream box after it. + fn jp2(components: u16, colrs: &[&[u8]]) -> Vec { + let jp2_box = |kind: &[u8], body: &[u8]| { + let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + }; + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 1]); + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); + let mut header = jp2_box(b"ihdr", &ihdr); + for c in colrs { + header.extend(jp2_box(b"colr", c)); + } + let mut out = jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "); + out.extend(jp2_box(b"jp2h", &header)); + out.extend(jp2_box(b"jp2c", &[0xff, 0x4f])); + out + } + + fn enumerated(e: u32) -> Vec { + let mut c = alloc::vec![1, 0, 0]; + c.extend_from_slice(&e.to_be_bytes()); + c + } + + /// The enumerated colour spaces found in the copy's `colr` boxes, in order. + fn enums_in(data: &[u8]) -> Vec { + data.windows(4) + .enumerate() + .filter(|(_, w)| *w == b"colr") + .filter(|(i, _)| data.get(i + 4) == Some(&1)) + .map(|(i, _)| u32::from_be_bytes(data[i + 7..i + 11].try_into().unwrap())) + .collect() + } + + #[test] + fn refused_enumerated_colour_spaces_become_the_component_counts_default() { + // CIEJab (19) and other refused spaces, by component count. + for (components, refused, expected) in [(3, 19, 16), (3, 22, 16), (1, 15, 17), (4, 13, 12), (3, 1, 16)] { + let data = jp2(components, &[&enumerated(refused)]); + let out = without_refused_enumerated_colour(&data).unwrap(); + assert_eq!(enums_in(&out), [expected], "{refused} with {components} component(s)"); + assert_eq!(out.len(), data.len(), "only the enum changes"); + } + // Several colour specifications: each refused one, and only those. + let out = without_refused_enumerated_colour(&jp2(3, &[&enumerated(19), &enumerated(14), &enumerated(24)])).unwrap(); + assert_eq!(enums_in(&out), [16, 14, 16]); + // An 8-byte box length (LBox 1) is read too. + let mut long = jp2(3, &[&enumerated(19)]); + let at = long.windows(4).position(|w| w == b"jp2h").unwrap() - 4; + let len = u32::from_be_bytes(long[at..at + 4].try_into().unwrap()) as u64 + 8; + long.splice(at..at + 4, [0, 0, 0, 1]); + long.splice(at + 8..at + 8, (len).to_be_bytes()); + assert_eq!(enums_in(&without_refused_enumerated_colour(&long).unwrap()), [16]); + } + + #[test] + fn what_the_decoder_handles_or_cant_be_read_is_left_alone() { + for known in [12, 14, 16, 17, 18, 20, 21] { + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&enumerated(known)])), None, "{known}"); + } + // An ICC profile (METH 2), a component count with no default, no header at all. + // (Its bytes where an enum would be read say 19: only METH 1 is an enum.) + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&[2, 0, 0, 0, 0, 0, 19, 0, 0]])), None); + assert_eq!(without_refused_enumerated_colour(&jp2(2, &[&enumerated(19)])), None); + assert_eq!(without_refused_enumerated_colour(&[0xff, 0x4f, 0xff, 0x51]), None); + // Damaged lengths: none of these may panic, and none is changed. + let good = jp2(3, &[&enumerated(19)]); + for cut in 0..good.len() { + let _ = without_refused_enumerated_colour(&good[..cut]); + } + for bad in [0x0000_0004_u32, 0x7fff_ffff, 0xffff_ffff] { + let mut data = good.clone(); + let at = data.windows(4).position(|w| w == b"colr").unwrap() - 4; + data[at..at + 4].copy_from_slice(&bad.to_be_bytes()); + assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}"); + } + } } diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index e65e83bd..9642a749 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -560,23 +560,27 @@ pub(crate) mod flate { /// Copies `left` bytes from `distance` back in the output, up to the limit. Returns how /// many are still to copy. A copy whose source does not exist writes nothing. - fn copy_back(&mut self, valid: bool, distance: usize, mut left: usize) -> usize { - while left > 0 && self.output.len() < self.limit { - if valid { - let byte = self - .output - .len() - .checked_sub(distance) - .and_then(|index| self.output.get(index)) - .copied(); - if let Some(byte) = byte { - self.output.push(byte); - } + fn copy_back(&mut self, valid: bool, distance: usize, left: usize) -> usize { + let len = self.output.len(); + // Without a source the copy writes nothing, and it runs out only at the limit. + let Some(mut start) = len.checked_sub(distance).filter(|_| valid && distance > 0) else { + return if len < self.limit { 0 } else { left }; + }; + // PdfCraft patch: copy in runs of up to `distance` bytes. Each run reads only bytes + // already in the output, so the bytes are the ones a byte-at-a-time copy writes. + let copy = left.min(self.limit.saturating_sub(len)); + let mut done = 0; + while done < copy { + let end = start.saturating_add((copy - done).min(distance)); + if end > self.output.len() { + break; } - left -= 1; + self.output.extend_from_within(start..end); + done += end - start; + start = end; } - left + left - done } fn read_dynamic_tables(&mut self) -> Option<(HuffmanTable, HuffmanTable)> { @@ -1318,6 +1322,52 @@ mod tests { ); } + /// PdfCraft patch: 300 zero bytes, zlib-compressed: one literal, then copies of distance 1 that + /// are longer than their distance. + const ZEROS_300: [u8; 13] = [0x78, 0xda, 0x63, 0x60, 0x18, 0x05, 0xc4, 0x02, 0x00, 0x01, 0x2c, 0x00, 0x01]; + + #[test] + fn back_reference_longer_than_its_distance_repeats_its_source() { + let mut state = flate::FlateState::new(&ZEROS_300); + let mut out = Vec::new(); + state.decode_into(&ZEROS_300, &mut out, 1000); + + assert_eq!(out, vec![0; 300]); + } + + #[test] + fn back_reference_cut_by_the_limit_resumes_in_the_next_call() { + let mut state = flate::FlateState::new(&ZEROS_300); + let mut out = Vec::new(); + state.decode_into(&ZEROS_300, &mut out, 100); + assert_eq!(out, vec![0; 100]); + + state.decode_into(&ZEROS_300, &mut out, 1000); + assert_eq!(out, vec![0; 300]); + } + + #[test] + fn back_reference_reads_bytes_written_by_an_earlier_call() { + // "abc" repeated 2000 times, zlib-compressed: copies reach 3 bytes back, and the calls below + // stop every 7 bytes, so most copies start in an earlier call's output. + const ABC_2000: [u8; 32] = [ + 0x78, 0xda, 0xed, 0xc2, 0x41, 0x11, 0x00, 0x00, 0x0c, 0x02, 0xa0, 0xac, 0x6a, 0xff, 0x0e, 0x0b, + 0xb1, 0x2f, 0x1c, 0xe9, 0xa2, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xfe, 0x1e, 0x2f, 0x01, 0xf9, 0x59, + ]; + let expected = b"abc".repeat(2000); + let mut state = flate::FlateState::new(&ABC_2000); + let mut out = Vec::new(); + loop { + let before = out.len(); + state.decode_into(&ABC_2000, &mut out, before + 7); + if out.len() == before || out.len() >= expected.len() { + break; + } + } + + assert_eq!(out, expected); + } + #[test] fn predictor_paeth() { predictor_test( diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 90ffd76e..e3622795 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -95,7 +95,7 @@ impl Filter { Self::AsciiHexDecode => ascii_hex::decode(data) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), - Self::Ascii85Decode => ascii_85::decode(data) + Self::Ascii85Decode => ascii_85::decode(data, limit) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), Self::RunLengthDecode => run_length::decode(data, limit) diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index c975ed9f..ac911e85 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -351,14 +351,25 @@ impl FilterResult<'_> { } } +/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services +/// writes `stream \r\n`, a space before the line break, which the specification does not allow. +/// Without this the content stream was taken to start at the space, failed to inflate, and the +/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and +/// tabs are skipped, and only when a line break follows, so stream data is never swallowed. +fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> { + r.forward_while(|b| b == b' ' || b == b'\t'); + r.forward_tag(b"\n") + .or_else(|| r.forward_tag(b"\r\n")) + // Technically not allowed, but no reason to not try it. + .or_else(|| r.forward_tag(b"\r")) +} + fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { let length = dict.get::(LENGTH)?; r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let data = r.read_bytes(length as usize)?; r.skip_white_spaces(); r.forward_tag(b"endstream")?; @@ -374,11 +385,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> // A wrong `/Length` still recovers, because that keyword sits directly after the dict. r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - // Technically not allowed, but no reason to not try it. - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let tail = r.tail()?; let endstream_offset = find_needle(tail, b"endstream")?; diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index 20d6adef..7f1740f4 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -604,9 +604,17 @@ impl<'a> Page<'a> { { (A4.width() as f32, A4.height() as f32) } else { + // PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit. + // The stored unit is finite and within 1..=75000. Round upward only if the + // reciprocal's f32 product would fall below one and trigger a placeholder. + let mut raw_floor = 1.0 / self.user_unit; + if raw_floor * self.user_unit < 1.0 { + raw_floor = raw_floor.next_up(); + } + let raw_floor = f64::from(raw_floor); ( - crop_box.width().max(1.0) as f32, - crop_box.height().max(1.0) as f32, + crop_box.width().max(raw_floor) as f32, + crop_box.height().max(raw_floor) as f32, ) } } diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index 50e0340a..260e2c55 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -1,7 +1,7 @@ { "crate": "hayro", "source_repository": "https://github.com/storytold/pdfcraft", - "source_revision": "7437bde378f43856eada40af25b983812e615e8a", + "source_revision": "ec0a965074f376a5bf610435948e4e5184bf3299", "source_directory": "vendor/hayro", "files": [ { @@ -60,15 +60,15 @@ }, { "path": "src/lib.rs", - "source_sha256": "1ce3e02072acb639c33476618446af80fca29b960a3ca764168c1c2d348d5e5d", - "source_git_blob": "def102b4291b1fe99f2c8975477e5d294c4f955f", - "sha256": "1ce3e02072acb639c33476618446af80fca29b960a3ca764168c1c2d348d5e5d" + "source_sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078", + "source_git_blob": "6333ed5808ae0cf9cf158e4730e973c1d7089bab", + "sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078" }, { "path": "src/renderer.rs", - "source_sha256": "b74b630b73e86b69b26ef433ee6aabbb684def133e4ae46f0cbf8f9f1fbd4484", - "source_git_blob": "23fc2e36cae7368e1a508bb246f3a1a15bb60a67", - "sha256": "b74b630b73e86b69b26ef433ee6aabbb684def133e4ae46f0cbf8f9f1fbd4484" + "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", + "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", + "sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b" } ] } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index def102b4..6333ed58 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode}; mod renderer; /// PdfCraft patch: exported for its regression test. -pub use renderer::{image_resampling_size, tiling_cell_scale}; +pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale}; /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; @@ -254,6 +254,16 @@ pub fn render_into<'a>( interpreter_settings.preview, ); + // PdfCraft patch (18): the page renders as one transparency group with the background + // painted *inside* it, as the initial backdrop. A group isolates what it wraps from what + // was painted before it, so with the background outside, a Multiply highlight blended + // only with the group's transparent backdrop and stayed its own colour — over a dark + // reading-colours background the whole quad became one flat bar and the words under it + // vanished. Inside the group the highlight multiplies with the background too: the words + // keep the highlight's colour and the band between them takes the background (white + // backgrounds are unaffected — multiplying with white is the identity). + device.push_transparency_group(1.0, None, BlendMode::Normal); + device.ctx.set_paint(render_settings.bg_color); device .ctx @@ -265,12 +275,10 @@ pub fn render_into<'a>( fill: FillRule::NonZero, }); - device.push_transparency_group(1.0, None, BlendMode::Normal); interpret_page(page, &mut state, &mut device); - device.pop_transparency_group(); - device.pop_clip_path(); + device.pop_transparency_group(); let mut resources = vello_cpu::Resources::default(); device.ctx.render_to_buffer( diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 23fc2e36..6aa53716 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -432,6 +432,9 @@ pub(crate) struct Renderer { /// PdfCraft patch: when set, solid text and line-art paint colours are remapped /// while rendering (the "reading colours" view setting). color_replacement: Option, + /// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their + /// colours — reading colours replace document colours, never annotation colours. + in_annotations: bool, 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 @@ -466,6 +469,7 @@ impl Renderer { Self { ctx: RenderContext::new_with(width, height, settings), color_replacement, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: cache.outline_cache.clone(), @@ -522,14 +526,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 +570,7 @@ 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, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1023,6 +1021,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); @@ -1075,8 +1078,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 +1135,65 @@ 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, + 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 +1216,7 @@ 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, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1464,6 +1532,7 @@ 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, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1608,6 +1677,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, @@ -1749,6 +1828,7 @@ 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, inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), @@ -1866,6 +1946,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. From 2c8c3ad261e85afe49cca596498f0abe93d9e9e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:40:50 +0000 Subject: [PATCH 02/11] vendor: port pdfcraft #2770 plate mode into hayro-interpret Bring the vendored interpreter part of storytold/pdfcraft#2770 (Output Preview, source commit 6fcf2a4aca29e943e28cb69017ff8f87d00656c6) onto the ec0a965 snapshot. It applies without conflicts. InterpreterSettings::plate draws one ink's separation: every paint draws that ink's tint as a gray film, by the colour space's ink use and 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 the spot inks paints use, and color::CmykSimulation converts process colour to an output intent's CMYK profile. PdfCraft's separations.rs, Output Preview UI and automation are not part of this port. The ledger in vendor/README.craft-reader.md names the pull request, author and source commit. Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> --- vendor/README.craft-reader.md | 7 + vendor/hayro-interpret/SOURCE.json | 16 +- vendor/hayro-interpret/src/color.rs | 1015 ++++++++++++++++- vendor/hayro-interpret/src/interpret/mod.rs | 10 + vendor/hayro-interpret/src/interpret/path.rs | 57 +- vendor/hayro-interpret/src/interpret/state.rs | 24 + vendor/hayro-interpret/src/pattern.rs | 26 +- vendor/hayro-interpret/src/shading.rs | 10 + vendor/hayro-interpret/src/soft_mask.rs | 8 +- vendor/hayro-interpret/src/x_object.rs | 52 +- 10 files changed, 1181 insertions(+), 44 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index dc956e6e..e77d422e 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -32,6 +32,13 @@ re-sync brings these PdfCraft changes: 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 licence. Code comments keep the pull request's patch numbers. + +| 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 colour to an output intent's CMYK. Only the vendored interpreter part is ported; PdfCraft's `separations.rs` and Output Preview UI are not | + The shared snapshot adds immutable per-document ordinary decode limits, threaded through initial xrefs, object streams, fallback and repair. Existing constructors retain their previous default. Known-size image budgets and streaming content budgets remain distinct. diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 2507cd07..3733e299 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "149d5a6b1b9e6212d2b3e9d63e1803a6041eb1798bfb9231ef89fc6bfcb7b968" + "sha256": "784753b972d9a24167c8085f4e387ac7cc85007f4ab4ca83ab0c0aafe067c073" }, { "path": "src/context.rs", @@ -362,19 +362,19 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "1ed42a7777a6265f14efaaa5899dc96b93dc53aff85afacfcac60065e6e490d5" + "sha256": "2f50fcd65da262f4ef66c9611adcdb993015c57181bc2a51a551611152a59f5f" }, { "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": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", - "sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32" + "sha256": "6e80c039ab7ca38ede2b1778709f1b4258d8cf219d044d3298f4a5f6dd2c8f9a" }, { "path": "src/soft_mask.rs", "source_sha256": "6cd9c59c0cff580879c1f06ff40f3537c04b719bdb3ea59008fa1ab7559db5dd", "source_git_blob": "4d97d565b53c820fa927fbba8c3dda6acead7f34", - "sha256": "780ffbf73567a7ae33f2fb673676840e0a29cb6b59f1d8b625034f42c4d17a22" + "sha256": "0e3871896575006f194fb46242fcaa6e1026f459c6144c38da438f82ace95e50" }, { "path": "src/types.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "48d296abf28696c4a3e01e9d12169c754062b0840c9f2b93051435247aba9144" + "sha256": "20e9d2f4fcafd20e11d6255f2800c8732ea84e74e5061287c8d5fbcfb4ffe013" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index af359741..55904609 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -2,6 +2,7 @@ use crate::cache::{Cache, CacheKey}; use crate::function::Function; +use crate::util::hash128; use hayro_syntax::object; use hayro_syntax::object::Array; use hayro_syntax::object::Dict; @@ -10,8 +11,8 @@ use hayro_syntax::object::Object; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::*; use moxcms::{ - ColorProfile, DataColorSpace, Layout, Transform8BitExecutor, TransformF32Executor, - TransformOptions, + ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, Transform8BitExecutor, + TransformF32Executor, TransformOptions, }; use smallvec::{SmallVec, ToSmallVec, smallvec}; use std::borrow::Cow; @@ -117,6 +118,8 @@ pub(crate) enum ColorSpaceType { Lab(Lab), Separation(Separation), DeviceN(DeviceN), + /// PdfCraft patch (35): `inner` drawn as the plate of one ink (see [`Plate`]). + Plate(PlateSpace), } impl ColorSpaceType { @@ -270,7 +273,12 @@ impl ColorSpace { /// Return `true` if the current color space is an indexed color space. pub(crate) fn is_indexed(&self) -> bool { - matches!(self.0.as_ref(), ColorSpaceType::Indexed(_)) + match self.0.as_ref() { + ColorSpaceType::Indexed(_) => true, + // PdfCraft patch (35): a plate decodes its samples as its space does. + ColorSpaceType::Plate(p) => p.inner.is_indexed(), + _ => false, + } } /// Get the default decode array for the color space. @@ -290,6 +298,7 @@ impl ColorSpace { ColorSpaceType::Indexed(_) => smallvec![(0.0, 2.0_f32.powf(n) - 1.0)], ColorSpaceType::Separation(_) => smallvec![(0.0, 1.0)], ColorSpaceType::DeviceN(d) => smallvec![(0.0, 1.0); d.num_components as usize], + ColorSpaceType::Plate(p) => p.inner.default_decode_arr(n), // Not a valid image color space. ColorSpaceType::Pattern(_) => smallvec![(0.0, 1.0)], } @@ -321,6 +330,7 @@ impl ColorSpace { ColorSpaceType::Separation(_) => smallvec![1.0], ColorSpaceType::Pattern(c) => c.initial_color(), ColorSpaceType::DeviceN(d) => smallvec![1.0; d.num_components as usize], + ColorSpaceType::Plate(p) => p.inner.initial_color(), } } @@ -342,6 +352,7 @@ impl ColorSpace { ColorSpaceType::Separation(_) => 1, ColorSpaceType::Pattern(p) => p.num_components(), ColorSpaceType::DeviceN(d) => d.num_components, + ColorSpaceType::Plate(p) => p.inner.num_components(), } } @@ -392,6 +403,7 @@ impl ToRgb for ColorSpace { ColorSpaceType::Lab(i) => i.convert_f32(input, output, manual_scale), ColorSpaceType::Separation(i) => i.convert_f32(input, output, manual_scale), ColorSpaceType::DeviceN(i) => i.convert_f32(input, output, manual_scale), + ColorSpaceType::Plate(i) => i.convert_f32(input, output, manual_scale), } } @@ -408,6 +420,7 @@ impl ToRgb for ColorSpace { ColorSpaceType::Lab(i) => i.supports_u8(), ColorSpaceType::Separation(i) => i.supports_u8(), ColorSpaceType::DeviceN(i) => i.supports_u8(), + ColorSpaceType::Plate(i) => i.supports_u8(), } } @@ -436,6 +449,7 @@ impl ToRgb for ColorSpace { ColorSpaceType::Lab(i) => i.convert_u8(input, output), ColorSpaceType::Separation(i) => i.convert_u8(input, output), ColorSpaceType::DeviceN(i) => i.convert_u8(input, output), + ColorSpaceType::Plate(i) => i.convert_u8(input, output), } } @@ -443,6 +457,7 @@ impl ToRgb for ColorSpace { match self.0.as_ref() { ColorSpaceType::Separation(s) => s.is_none(), ColorSpaceType::DeviceN(d) => d.is_none(), + ColorSpaceType::Plate(p) => p.inner.is_none(), _ => false, } } @@ -701,6 +716,11 @@ impl Lab { /// be expressed in LMS before taking their ratios. CalRGB's separate /// flat-white/black-point pipeline retains its existing normalization. fn adapt_to_d65(white_point: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { + Self::adapt(white_point, &CalRgb::D65_WHITEPOINT, xyz) + } + + /// `xyz`, seen under `white_point`, Bradford-adapted to `destination`. + fn adapt(white_point: &[f32; 3], destination: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { let source_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, white_point); // A malformed but positive XYZ white can still yield a zero or // overflowing cone response. Leave it unadapted rather than divide by @@ -708,7 +728,7 @@ impl Lab { if source_lms.iter().any(|v| !v.is_finite() || v.abs() <= f32::EPSILON) { return *xyz; } - let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, &CalRgb::D65_WHITEPOINT); + let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, destination); let lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, xyz); let adapted = [ lms[0] * destination_lms[0] / source_lms[0], @@ -728,43 +748,60 @@ impl Lab { 3.0 * DELTA * DELTA * (t - 4.0 / 29.0) } } + + /// PdfCraft patch: the L*, a* and b* of the colour `input`, clamped to L* 0..100 and the + /// a*/b* /Range. `manual_scale` means the components arrive normalised to [0, 1] (an + /// `/Indexed` palette over Lab looks its bytes up as `byte / 255`): they map linearly onto + /// L* 0..100 and the /Range (ISO 32000-2 §8.6.6.3). + fn components(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { + let [min_a, max_a, min_b, max_b] = self.range; + let get = |k: usize| input.get(k).copied().unwrap_or(0.0); + let (l, a, b) = if manual_scale { + ( + clamp_component(get(0), 0.0, 1.0) * 100.0, + min_a + clamp_component(get(1), 0.0, 1.0) * (max_a - min_a), + min_b + clamp_component(get(2), 0.0, 1.0) * (max_b - min_b), + ) + } else { + (get(0), get(1), get(2)) + }; + [ + clamp_component(l, 0.0, 100.0), + clamp_component(a, min_a, max_a), + clamp_component(b, min_b, max_b), + ] + } + + /// The XYZ of L*a*b*, in the coordinates of the space's white point. + fn xyz(&self, [l, a, b]: [f32; 3]) -> [f32; 3] { + let [xw, yw, zw] = self.usable_white_point(); + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + [ + xw * Self::inverse_lab_f(fx), + yw * Self::inverse_lab_f(fy), + zw * Self::inverse_lab_f(fz), + ] + } + + /// PdfCraft patch (36): the colour `input` in the separation hub (see [`Hub`]), relative + /// colorimetric: the space's white point becomes the PCS white (Bradford-adapted). + fn to_hub(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { + let xyz = self.xyz(self.components(input, manual_scale)); + HUB.from_xyz(&Self::adapt(&self.usable_white_point(), &HUB.white, &xyz)) + } } impl ToRgb for Lab { fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> { - let [min_a, max_a, min_b, max_b] = self.range; let white_point = self.usable_white_point(); - let [xw, yw, zw] = white_point; for (input, output) in input.chunks_exact(3).zip(output.chunks_exact_mut(3)) { - // PdfCraft patch: `manual_scale` means the components arrive - // normalised to [0, 1] (an `/Indexed` palette over Lab looks its - // bytes up as `byte / 255`). Map them linearly onto L* 0..100 and - // the a*/b* /Range (ISO 32000-2 §8.6.6.3) before converting. - let (l, a, b) = if manual_scale { - ( - clamp_component(input[0], 0.0, 1.0) * 100.0, - min_a + clamp_component(input[1], 0.0, 1.0) * (max_a - min_a), - min_b + clamp_component(input[2], 0.0, 1.0) * (max_b - min_b), - ) - } else { - (input[0], input[1], input[2]) - }; - let l = clamp_component(l, 0.0, 100.0); - let a = clamp_component(a, min_a, max_a); - let b = clamp_component(b, min_b, max_b); - // L*a*b* is relative to the space's own /WhitePoint. Map it to XYZ // in that white's coordinates, Bradford-adapt to D65 and convert to // sRGB (ISO 32000-2 §8.6.5.4). - let fy = (l + 16.0) / 116.0; - let fx = fy + a / 500.0; - let fz = fy - b / 200.0; - let xyz = [ - xw * Self::inverse_lab_f(fx), - yw * Self::inverse_lab_f(fy), - zw * Self::inverse_lab_f(fz), - ]; + let xyz = self.xyz(self.components(input, manual_scale)); let xyz_d65 = Self::adapt_to_d65(&white_point, &xyz); let srgb = CalRgb::matrix_product(&CalRgb::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65); @@ -852,6 +889,8 @@ pub(crate) struct Separation { alternate_space: ColorSpace, tint_transform: Function, is_none_separation: bool, + /// PdfCraft patch (35): the colorant the space paints, for separations. + colorant: Colorant, } impl Separation { @@ -865,11 +904,13 @@ impl Separation { // Either I did something wrong, or no other viewers properly handles // `All`, so let's just ignore it as well. let is_none_separation = name.as_str() == "None"; + let colorant = Colorant::from_name(name.deref()); Some(Self { alternate_space, tint_transform, is_none_separation, + colorant, }) } } @@ -899,6 +940,8 @@ pub(crate) struct DeviceN { num_components: u8, tint_transform: Function, is_none: bool, + /// PdfCraft patch (35): the colorant of each component, for separations. + colorants: Vec, } impl DeviceN { @@ -920,11 +963,22 @@ impl DeviceN { return None; } + // `All` names every colorant only in a Separation (ISO 32000-2 §8.6.6.5), so a DeviceN + // component named `All` paints nothing in particular. + let colorants = names + .iter() + .map(|n| match Colorant::from_name(n.deref()) { + Colorant::All => Colorant::None, + c => c, + }) + .collect(); + Some(Self { alternate_space, num_components, tint_transform, is_none: all_none, + colorants, }) } } @@ -957,6 +1011,12 @@ struct ICCColorRepr { is_lab: bool, transform_u8: Arc, transform_f32: OnceLock>, + /// PdfCraft patch (36): a digest of the profile's bytes, to tell a colour in a + /// [`CmykSimulation`]'s own profile from one in another. + digest: u128, + /// PdfCraft patch (36): the transform into the separation hub (see [`Hub`]), made on first + /// use; `None` when the profile doesn't convert to it. + to_hub: OnceLock>>, } #[derive(Clone)] @@ -980,7 +1040,13 @@ impl ICCProfile { .unwrap_or(false); let is_lab = src_profile.color_space == DataColorSpace::Lab; - Self::new_from_src_profile(src_profile, is_srgb, is_lab, number_components) + Self::new_from_src_profile( + src_profile, + is_srgb, + is_lab, + number_components, + hash128(profile), + ) } fn new_from_src_profile( @@ -988,6 +1054,7 @@ impl ICCProfile { is_srgb: bool, is_lab: bool, number_components: usize, + digest: u128, ) -> Option { let src_layout = match number_components { 1 => Layout::Gray, @@ -1019,9 +1086,46 @@ impl ICCProfile { is_lab, transform_u8, transform_f32: OnceLock::new(), + digest, + to_hub: OnceLock::new(), }))) } + /// PdfCraft patch (36): `input` (whole colours of this profile's components) in the + /// separation hub, three values per colour in `rgb`. L*a*b* components are scaled to the + /// profile's encoding unless `manual_scale` says they already are (an Indexed palette's). + fn to_hub(&self, input: &[f32], manual_scale: bool, rgb: &mut [f32]) -> Option<()> { + let transform = self + .0 + .to_hub + .get_or_init(|| { + transform_f32( + &self.0.src_profile, + self.0.src_layout, + &HUB.profile, + Layout::Rgb, + ) + .map(|(transform, _)| transform) + }) + .as_ref()?; + let n = self.0.number_components; + let samples = input.len() / n.max(1); + if samples > SIMULATION_CHUNK || rgb.len() != samples.checked_mul(3)? { + return None; + } + // Values outside the encoding (or NaN) never reach the tables. + let mut encoded = [0.0_f32; SIMULATION_CHUNK * 4]; + let encoded = encoded.get_mut(..samples.checked_mul(n)?)?; + for (e, (k, v)) in encoded.iter_mut().zip(input.iter().enumerate()) { + *e = match (self.is_lab() && !manual_scale, k % 3) { + (true, 0) => unit(*v / 100.0), + (true, _) => unit((*v + 128.0) / 255.0), + (false, _) => unit(*v), + }; + } + transform.transform(encoded, rgb).ok() + } + fn is_srgb(&self) -> bool { self.0.is_srgb } @@ -1144,6 +1248,853 @@ impl Color { opacity: c[3], } } + + /// PdfCraft patch (35): the gray film of `tint` on a plate (see [`Plate`]). + pub(crate) fn film(tint: f32, opacity: f32) -> Self { + Self::new( + ColorSpace::device_gray(), + smallvec![1.0 - unit(tint)], + opacity, + ) + } + + /// PdfCraft patch (35): this colour as drawn on `plate`. `overprint` is the graphics + /// state's overprint parameter for this paint (`OP` for strokes, `op` otherwise) and + /// `overprint_mode` its `OPM` (ISO 32000-2 §8.6.7). A paint that leaves the plate as it is + /// becomes fully transparent. + pub(crate) fn for_plate(&self, plate: &Plate, overprint: bool, overprint_mode: u8) -> Self { + let overprint = plate.overprint && overprint; + let untouched = Self::film(0.0, 0.0); + match self.color_space.ink_use(&plate.ink) { + InkUse::Nothing => untouched, + InkUse::Unspecified if overprint => untouched, + InkUse::Unspecified => Self::film(0.0, self.opacity), + InkUse::Paints => { + let tint = self.color_space.ink_tint( + &self.components, + &plate.ink, + plate.simulation.as_ref(), + false, + ); + // Nonzero overprint mode: a DeviceCMYK component of 0 leaves its plate as it + // is. It applies to colours set in DeviceCMYK itself, not to images, shadings + // or colours converted from another space. + if overprint + && overprint_mode == 1 + && self.color_space.is_device_cmyk() + && tint <= 0.0 + { + untouched + } else { + Self::film(tint, self.opacity) + } + } + } + } +} + +/// PdfCraft patch (35): a printing ink, the unit of a separation: one of the four process +/// colorants or a spot colorant named by a `Separation` or `DeviceN` colour space (ISO 32000-2 +/// §8.6.6.4). +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Ink { + /// Process cyan. + Cyan, + /// Process magenta. + Magenta, + /// Process yellow. + Yellow, + /// Process black. + Black, + /// A spot colorant, by its name in the colour space. + Spot(Arc), +} + +impl Ink { + /// The four process inks, in plate order. + pub const PROCESS: [Ink; 4] = [Ink::Cyan, Ink::Magenta, Ink::Yellow, Ink::Black]; + + /// The ink's colorant name, as a `Separation` colour space spells it. + pub fn name(&self) -> &str { + match self { + Self::Cyan => "Cyan", + Self::Magenta => "Magenta", + Self::Yellow => "Yellow", + Self::Black => "Black", + Self::Spot(name) => name, + } + } + + /// The ink's position in a CMYK colour, for a process ink. + pub(crate) fn process_index(&self) -> Option { + match self { + Self::Cyan => Some(0), + Self::Magenta => Some(1), + Self::Yellow => Some(2), + Self::Black => Some(3), + Self::Spot(_) => None, + } + } +} + +/// PdfCraft patch (35): what a `Separation` or `DeviceN` colorant name paints (ISO 32000-2 +/// §8.6.6.4): an ink (the process names are the process inks), every ink (`All`), or nothing +/// (`None`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Colorant { + Ink(Ink), + All, + None, +} + +impl Colorant { + fn from_name(name: &[u8]) -> Self { + match name { + b"Cyan" => Self::Ink(Ink::Cyan), + b"Magenta" => Self::Ink(Ink::Magenta), + b"Yellow" => Self::Ink(Ink::Yellow), + b"Black" => Self::Ink(Ink::Black), + b"All" => Self::All, + b"None" => Self::None, + other => Self::Ink(Ink::Spot(Arc::from( + String::from_utf8_lossy(other).as_ref(), + ))), + } + } +} + +/// PdfCraft patch (35): draw one ink's separation instead of the composite page. Every paint +/// draws the ink's tint as a gray film: white where the plate carries no ink, black where it +/// carries 100%. Set through [`InterpreterSettings::plate`](crate::InterpreterSettings::plate). +#[derive(Clone, Debug, PartialEq)] +pub struct Plate { + /// The ink whose separation is drawn. + pub ink: Ink, + /// Honour the overprint parameters `OP`, `op` and `OPM` (ISO 32000-2 §8.6.7) as a + /// separating device does: a paint whose colour space doesn't specify this ink leaves the + /// plate as it is. When `false`, every paint knocks out what is under it, as on a + /// composite device. + pub overprint: bool, + /// PdfCraft patch (36): the printing condition process colour converts to, from the + /// document's output intent. `None` converts by the ISO 32000-2 §10.4.2 device formulas. + pub simulation: Option, +} + +/// PdfCraft patch (35): told about each spot ink a paint uses, with the ink's appearance: the +/// sRGB colour of that ink alone at a tint in `0.0..=1.0`, through the colour space's +/// alternate space and tint transform. +pub type InkSinkFn = Arc [u8; 3]) + Send + Sync>; + +/// PdfCraft patch (35): how a colour space paints one ink's plate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InkUse { + /// The space specifies the ink: its colours set the plate's tint. + Paints, + /// The space doesn't specify the ink. Overprinting leaves the plate alone; otherwise the + /// paint knocks it out (tint 0). + Unspecified, + /// The space paints nothing at all (the `None` colorant). + Nothing, +} + +/// PdfCraft patch (35): DeviceCMYK to sRGB as pages are drawn (the bundled CGATS-compatible +/// profile), to compose separations back into a preview. `cmyk` holds whole pixels of four +/// bytes and `rgb` the same number of three-byte pixels; `None` when the lengths disagree or +/// the transform fails. +pub fn cmyk_to_srgb8(cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { + return None; + } + CMYK_TRANSFORM.convert_u8(cmyk, rgb) +} + +/// A component as a tint: clamped to `0.0..=1.0`, NaN read as no ink. +fn unit(value: f32) -> f32 { + clamp_component(value, 0.0, 1.0) +} + +/// The DeviceCMYK colour of a DeviceRGB one by ISO 32000-2 §10.4.2.4, with black generation +/// and undercolour removal both the identity (`BG(k) = UCR(k) = k`). Those two functions are +/// device dependent; the identity is the simplest choice the formulas allow. +fn rgb_to_cmyk([r, g, b]: [f32; 3]) -> [f32; 4] { + let (c, m, y) = (1.0 - unit(r), 1.0 - unit(g), 1.0 - unit(b)); + let k = c.min(m).min(y); + [unit(c - k), unit(m - k), unit(y - k), unit(k)] +} + +/// PdfCraft patch (36): colours converted per step through a [`CmykSimulation`], so that the +/// buffers stay on the stack whatever an image's size. +const SIMULATION_CHUNK: usize = 256; + +/// PdfCraft patch (36): the printing condition separations simulate, from an output intent's +/// destination profile (ISO 32000-2 §14.11.5): a CMYK ICC profile with PCS-to-device (BToA) +/// tables. Set through [`Plate::simulation`]. Process colour in other spaces converts to the +/// profile's CMYK through those tables, instead of by the device formulas of §10.4.2: +/// +/// - DeviceRGB as sRGB, as pages are drawn; CalRGB and CalGray through their sRGB colours; +/// - Lab and ICCBased colours (RGB, gray, L\*a\*b\*, and CMYK in another profile) through +/// the separation hub, a D50 RGB wide enough for every surface colour (see [`Hub`]); +/// - DeviceCMYK, ICCBased CMYK in this same profile, and DeviceGray stay as they are: gray is a +/// device colour, which a CMYK device prints with black ink alone (§10.4.2.2). +/// +/// Conversions are relative colorimetric, the initial rendering intent (§8.6.5.8; the +/// graphics state's `ri` isn't read). A profile without colorimetric tables is used through +/// its perceptual ones (tag 0, which every LUT-based ICC.1 profile has). Black point +/// compensation isn't applied. +#[derive(Clone)] +pub struct CmykSimulation(Arc); + +struct SimulationRepr { + description: Option, + perceptual: bool, + digest: u128, + from_srgb: Arc, + from_hub: Arc, + to_srgb: Option>, +} + +impl Debug for CmykSimulation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CmykSimulation") + .field("description", &self.0.description) + .field("perceptual", &self.0.perceptual) + .finish_non_exhaustive() + } +} + +/// Two simulations of the same profile are the same. +impl PartialEq for CmykSimulation { + fn eq(&self, other: &Self) -> bool { + self.0.digest == other.0.digest + } +} + +/// PdfCraft patch (36): why an ICC profile can't be simulated. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoSimulation { + /// The profile doesn't parse. + Unreadable, + /// Its data colour space isn't CMYK. + NotCmyk, + /// It has no PCS-to-device (BToA) tables, or they don't convert. + NoPcsToDevice, +} + +impl CmykSimulation { + /// The longest description kept, in characters. + const MAX_DESCRIPTION: usize = 160; + + /// The printing condition of the ICC profile `profile`, if it is a CMYK profile whose + /// PCS-to-device tables convert. + pub fn new(profile: &[u8]) -> Result { + let parsed = ColorProfile::new_from_slice(profile).map_err(|_| NoSimulation::Unreadable)?; + if parsed.color_space != DataColorSpace::Cmyk { + return Err(NoSimulation::NotCmyk); + } + if parsed.lut_b_to_a_colorimetric.is_none() && parsed.lut_b_to_a_perceptual.is_none() { + return Err(NoSimulation::NoPcsToDevice); + } + let (from_hub, perceptual) = + transform_f32(&HUB.profile, Layout::Rgb, &parsed, Layout::Rgba) + .ok_or(NoSimulation::NoPcsToDevice)?; + let (from_srgb, _) = transform_f32(&SRGB, Layout::Rgb, &parsed, Layout::Rgba) + .ok_or(NoSimulation::NoPcsToDevice)?; + let to_srgb = transform_u8(&parsed, Layout::Rgba, &SRGB, Layout::Rgb); + Ok(Self(Arc::new(SimulationRepr { + description: profile_description(&parsed, Self::MAX_DESCRIPTION), + perceptual, + digest: hash128(profile), + from_srgb, + from_hub, + to_srgb, + }))) + } + + /// The profile's description (its `desc` tag) without control characters; `None` when it + /// has none. + pub fn description(&self) -> Option<&str> { + self.0.description.as_deref() + } + + /// Whether conversions use the profile's perceptual tables, because it has no + /// colorimetric ones. + pub fn perceptual(&self) -> bool { + self.0.perceptual + } + + /// A digest of the profile's bytes, for cache keys. + pub(crate) fn digest(&self) -> u128 { + self.0.digest + } + + /// CMYK in this printing condition to sRGB, relative colorimetric, to show what it prints. + /// As [`cmyk_to_srgb8`]; also `None` when the profile has no device-to-PCS tables. + pub fn cmyk_to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { + return None; + } + self.0.to_srgb.as_ref()?.transform(cmyk, rgb).ok() + } +} + +/// PdfCraft patch (36): a profile's description, without control characters and at most `max` +/// characters long; `None` when it has none. +fn profile_description(profile: &ColorProfile, max: usize) -> Option { + let text = match profile.description.as_ref()? { + ProfileText::PlainString(s) => s.as_str(), + ProfileText::Localizable(list) => { + &list + .iter() + .find(|s| s.language == "en") + .or(list.first())? + .value + } + ProfileText::Description(d) if d.ascii_string.trim().is_empty() => &d.unicode_string, + ProfileText::Description(d) => &d.ascii_string, + }; + let clean: String = text + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(max) + .collect(); + let clean = clean.trim(); + (!clean.is_empty()).then(|| clean.to_string()) +} + +/// PdfCraft patch (36): a transform from `source` to `destination`, relative colorimetric, or +/// through the perceptual tables (`true`) where a profile lacks colorimetric ones. +fn transform_f32( + source: &ColorProfile, + source_layout: Layout, + destination: &ColorProfile, + destination_layout: Layout, +) -> Option<(Arc, bool)> { + [ + RenderingIntent::RelativeColorimetric, + RenderingIntent::Perceptual, + ] + .into_iter() + .find_map(|rendering_intent| { + let options = TransformOptions { + rendering_intent, + ..TransformOptions::default() + }; + source + .create_transform_f32(source_layout, destination, destination_layout, options) + .ok() + .map(|t| (t, rendering_intent == RenderingIntent::Perceptual)) + }) +} + +/// PdfCraft patch (36): as [`transform_f32`], for bytes. +fn transform_u8( + source: &ColorProfile, + source_layout: Layout, + destination: &ColorProfile, + destination_layout: Layout, +) -> Option> { + [ + RenderingIntent::RelativeColorimetric, + RenderingIntent::Perceptual, + ] + .into_iter() + .find_map(|rendering_intent| { + let options = TransformOptions { + rendering_intent, + ..TransformOptions::default() + }; + source + .create_transform_8bit(source_layout, destination, destination_layout, options) + .ok() + }) +} + +static SRGB: LazyLock = LazyLock::new(ColorProfile::new_srgb); + +/// PdfCraft patch (36): the separation hub, the RGB that Lab and ICCBased colours pass through +/// on their way to a simulated CMYK: ROMM RGB (ProPhoto) primaries, D50 white, gamma 1.8. Its +/// gamut holds practically every surface colour, so nothing printable clips on the way, and +/// paper white and black land on exact grid points of the conversion's tables. (moxcms +/// converts CMYK to CMYK and gray to CMYK only through an intermediate.) +struct Hub { + profile: ColorProfile, + /// XYZ to linear hub RGB, row-major. + from_xyz: [f32; 9], + /// The XYZ of hub white, `(1, 1, 1)`. + white: [f32; 3], +} + +impl Hub { + const GAMMA: f32 = 1.8; + + fn new() -> Self { + let profile = ColorProfile::new_pro_photo_rgb(); + let to_xyz = profile.rgb_to_xyz_matrix(); + let from_xyz = to_xyz.inverse().v; + let white = to_xyz.v.map(|row| (row[0] + row[1] + row[2]) as f32); + Self { + profile, + from_xyz: [ + from_xyz[0][0] as f32, + from_xyz[0][1] as f32, + from_xyz[0][2] as f32, + from_xyz[1][0] as f32, + from_xyz[1][1] as f32, + from_xyz[1][2] as f32, + from_xyz[2][0] as f32, + from_xyz[2][1] as f32, + from_xyz[2][2] as f32, + ], + white, + } + } + + /// D50 XYZ (Y of white 1) as hub RGB, clipped to its gamut. + fn from_xyz(&self, xyz: &[f32; 3]) -> [f32; 3] { + CalRgb::matrix_product(&self.from_xyz, xyz).map(|v| unit(v).powf(1.0 / Self::GAMMA)) + } +} + +static HUB: LazyLock = LazyLock::new(Hub::new); + +impl ColorSpace { + /// PdfCraft patch (35): this space drawn as `ink`'s plate (for images and shadings, whose + /// colours are converted sample by sample), converting process colour through + /// `simulation` (patch 36). + pub(crate) fn plate(&self, ink: Ink, simulation: Option) -> Self { + Self(Arc::new(ColorSpaceType::Plate(PlateSpace { + inner: self.clone(), + ink, + simulation, + }))) + } + + /// PdfCraft patch (36): whether `simulation` converts this space's colours (see + /// [`CmykSimulation`]). + fn simulated_by(&self, simulation: &CmykSimulation) -> bool { + match self.0.as_ref() { + ColorSpaceType::DeviceRgb + | ColorSpaceType::CalRgb(_) + | ColorSpaceType::CalGray(_) + | ColorSpaceType::Lab(_) => true, + ColorSpaceType::ICCBased(icc) => { + icc.0.number_components != 4 || icc.0.digest != simulation.0.digest + } + _ => false, + } + } + + /// PdfCraft patch (36): the colours `input` (whole colours of this space's components; + /// `manual_scale` as for [`ToRgb::convert_f32`]) in `simulation`'s CMYK, four values per + /// colour in `cmyk`. `None` when the space isn't [`simulated_by`](Self::simulated_by) + /// `simulation`, the lengths disagree, or a conversion fails. + fn simulate_cmyk( + &self, + simulation: &CmykSimulation, + input: &[f32], + manual_scale: bool, + cmyk: &mut [f32], + ) -> Option<()> { + let n = usize::from(self.num_components()).max(1); + let colours = input.len() / n; + if cmyk.len() != colours.checked_mul(4)? { + return None; + } + let mut rgb = [0.0_f32; SIMULATION_CHUNK * 3]; + let mut srgb = [0_u8; SIMULATION_CHUNK * 3]; + for (input, cmyk) in input + .chunks(SIMULATION_CHUNK * n) + .zip(cmyk.chunks_mut(SIMULATION_CHUNK * 4)) + { + let count = cmyk.len() / 4; + let input = input.get(..count.checked_mul(n)?)?; + let rgb = rgb.get_mut(..count * 3)?; + let to_cmyk = match self.0.as_ref() { + ColorSpaceType::DeviceRgb => { + for (out, v) in rgb.iter_mut().zip(input) { + *out = unit(*v); + } + &simulation.0.from_srgb + } + ColorSpaceType::CalRgb(_) | ColorSpaceType::CalGray(_) => { + let srgb = srgb.get_mut(..count * 3)?; + self.convert_f32(input, srgb, manual_scale)?; + for (out, v) in rgb.iter_mut().zip(srgb.iter()) { + *out = u8_to_f32(*v); + } + &simulation.0.from_srgb + } + ColorSpaceType::Lab(lab) => { + for (out, colour) in rgb.chunks_exact_mut(3).zip(input.chunks_exact(3)) { + out.copy_from_slice(&lab.to_hub(colour, manual_scale)); + } + &simulation.0.from_hub + } + ColorSpaceType::ICCBased(icc) if self.simulated_by(simulation) => { + icc.to_hub(input, manual_scale, rgb)?; + &simulation.0.from_hub + } + _ => return None, + }; + to_cmyk.transform(rgb, cmyk).ok()?; + } + Some(()) + } + + /// PdfCraft patch (35): DeviceCMYK itself (`CalCMYK` reads as it too), where the nonzero + /// overprint mode applies. + pub(crate) fn is_device_cmyk(&self) -> bool { + matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk) + } + + /// PdfCraft patch (35): spaces whose colours are converted to sRGB before separation + /// (DeviceRGB is separated from its components directly, without a conversion). + fn separates_through_srgb(&self) -> bool { + match self.0.as_ref() { + ColorSpaceType::CalRgb(_) | ColorSpaceType::Lab(_) => true, + ColorSpaceType::ICCBased(icc) => icc.0.number_components == 3, + _ => false, + } + } + + /// PdfCraft patch (35): how the space paints `ink`'s plate. Gray, RGB, CIE-based and ICC + /// spaces are process colour: they specify the four process inks and no spot ink. + pub(crate) fn ink_use(&self, ink: &Ink) -> InkUse { + match self.0.as_ref() { + ColorSpaceType::DeviceCmyk + | ColorSpaceType::DeviceGray + | ColorSpaceType::DeviceRgb + | ColorSpaceType::ICCBased(_) + | ColorSpaceType::CalGray(_) + | ColorSpaceType::CalRgb(_) + | ColorSpaceType::Lab(_) => { + if ink.process_index().is_some() { + InkUse::Paints + } else { + InkUse::Unspecified + } + } + ColorSpaceType::Indexed(i) => i.base.ink_use(ink), + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::None => InkUse::Nothing, + Colorant::All => InkUse::Paints, + Colorant::Ink(i) if i == ink => InkUse::Paints, + Colorant::Ink(_) => InkUse::Unspecified, + }, + ColorSpaceType::DeviceN(d) => { + if d.is_none { + InkUse::Nothing + } else if d + .colorants + .iter() + .any(|c| matches!(c, Colorant::Ink(i) if i == ink)) + { + InkUse::Paints + } else { + InkUse::Unspecified + } + } + // A pattern is separated through what it paints. + ColorSpaceType::Pattern(_) => InkUse::Unspecified, + ColorSpaceType::Plate(p) => p.inner.ink_use(ink), + } + } + + /// PdfCraft patch (35): the tint, `0.0` (no ink) to `1.0` (solid), that the colour + /// `components` puts on `ink`'s plate; `0.0` when the space doesn't specify the ink. + /// Process colour converts through `simulation` when there is one (patch 36). + /// `manual_scale` as for [`ToRgb::convert_f32`] (components normalised to `0.0..=1.0`, as + /// an Indexed palette gives them). + pub(crate) fn ink_tint( + &self, + components: &[f32], + ink: &Ink, + simulation: Option<&CmykSimulation>, + manual_scale: bool, + ) -> f32 { + let tint = match self.0.as_ref() { + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::All => components.first().copied().unwrap_or(0.0), + Colorant::Ink(i) if i == ink => components.first().copied().unwrap_or(0.0), + _ => 0.0, + }, + ColorSpaceType::DeviceN(d) => d + .colorants + .iter() + .position(|c| matches!(c, Colorant::Ink(i) if i == ink)) + .and_then(|k| components.get(k)) + .copied() + .unwrap_or(0.0), + ColorSpaceType::Indexed(i) => { + let index = components.first().copied().unwrap_or(0.0); + let index = (clamp_component(index, 0.0, f32::from(i.hival)) + 0.5) as usize; + match i.values.get(index) { + Some(values) => i.base.ink_tint(values, ink, simulation, true), + None => 0.0, + } + } + ColorSpaceType::Pattern(_) => 0.0, + ColorSpaceType::Plate(p) => p.inner.ink_tint(components, ink, simulation, manual_scale), + _ => match ( + self.process_cmyk(components, simulation, manual_scale), + ink.process_index(), + ) { + (Some(cmyk), Some(k)) => cmyk.get(k).copied().unwrap_or(0.0), + _ => 0.0, + }, + }; + unit(tint) + } + + /// PdfCraft patch (35): a process colour as DeviceCMYK: CMYK spaces (DeviceCMYK, ICC with + /// four components) as they are, gray as black only (`k = 1 − gray`, §10.4.2.2), and RGB, + /// CIE-based and three-component ICC spaces through sRGB by [`rgb_to_cmyk`]. Patch 32: + /// the spaces `simulation` converts go through it instead, and only fall back to those + /// formulas if the conversion fails. + fn process_cmyk( + &self, + components: &[f32], + simulation: Option<&CmykSimulation>, + manual_scale: bool, + ) -> Option<[f32; 4]> { + if let Some(simulation) = simulation.filter(|s| self.simulated_by(s)) { + let n = usize::from(self.num_components()).max(1); + let mut colour: ColorComponents = components.iter().copied().take(n).collect(); + colour.resize(n, 0.0); + let mut cmyk = [0.0; 4]; + if self + .simulate_cmyk(simulation, &colour, manual_scale, &mut cmyk) + .is_some() + { + return Some(cmyk.map(unit)); + } + } + let get = |k: usize| unit(components.get(k).copied().unwrap_or(0.0)); + match self.0.as_ref() { + ColorSpaceType::DeviceCmyk => Some([get(0), get(1), get(2), get(3)]), + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 4 => { + Some([get(0), get(1), get(2), get(3)]) + } + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 1 => { + Some([0.0, 0.0, 0.0, 1.0 - get(0)]) + } + ColorSpaceType::DeviceGray | ColorSpaceType::CalGray(_) => { + Some([0.0, 0.0, 0.0, 1.0 - get(0)]) + } + ColorSpaceType::DeviceRgb => Some(rgb_to_cmyk([get(0), get(1), get(2)])), + _ if self.separates_through_srgb() => { + let mut rgb = [0_u8; 3]; + self.convert_sample(components, &mut rgb, manual_scale)?; + Some(rgb_to_cmyk(rgb.map(u8_to_f32))) + } + _ => None, + } + } + + /// PdfCraft patch (35): tell `sink` about every spot ink the space names, with the ink's + /// appearance (the space's colour with only that component set). + pub(crate) fn report_spots(&self, sink: &InkSinkFn) { + match self.0.as_ref() { + ColorSpaceType::Separation(s) => { + if let Colorant::Ink(ink @ Ink::Spot(_)) = &s.colorant { + sink(ink, &|tint| self.appearance(&[tint])); + } + } + ColorSpaceType::DeviceN(d) => { + for (k, colorant) in d.colorants.iter().enumerate() { + if let Colorant::Ink(ink @ Ink::Spot(_)) = colorant { + sink(ink, &|tint| { + let mut components: ColorComponents = smallvec![0.0; d.colorants.len()]; + if let Some(c) = components.get_mut(k) { + *c = tint; + } + self.appearance(&components) + }); + } + } + } + ColorSpaceType::Indexed(i) => i.base.report_spots(sink), + ColorSpaceType::Pattern(cs) => cs.report_spots(sink), + ColorSpaceType::Plate(p) => p.inner.report_spots(sink), + _ => {} + } + } + + /// The sRGB colour of `components` (the `None` colorant's invisibility aside). + fn appearance(&self, components: &[f32]) -> [u8; 3] { + let [r, g, b, _] = self.to_rgba(components, 1.0, false).to_rgba8(); + [r, g, b] + } +} + +/// PdfCraft patch (35): a colour space drawn as one ink's plate: each colour becomes the gray +/// film of that ink's tint (see [`Plate`]). +#[derive(Debug, Clone)] +pub(crate) struct PlateSpace { + inner: ColorSpace, + ink: Ink, + /// Patch 32: the printing condition process colour converts to, if any. + simulation: Option, +} + +impl PlateSpace { + fn samples(&self) -> usize { + usize::from(self.inner.num_components()).max(1) + } + + /// The film of the tint the colour `sample` puts on the plate. + fn film(&self, sample: &[f32], manual_scale: bool) -> u8 { + let tint = self + .inner + .ink_tint(sample, &self.ink, self.simulation.as_ref(), manual_scale); + f32_to_u8(1.0 - tint) + } + + /// PdfCraft patch (36): the inner space's colours `input` converted through the + /// simulation, as film; `None` when the space isn't simulated or a conversion fails. + fn film_through_simulation( + &self, + input: &[f32], + output: &mut [u8], + manual_scale: bool, + ) -> Option<()> { + let simulation = self + .simulation + .as_ref() + .filter(|s| self.inner.simulated_by(s))?; + let Some(k) = self.ink.process_index() else { + output.fill(255); + return Some(()); + }; + let n = self.samples(); + let mut cmyk = [0.0_f32; SIMULATION_CHUNK * 4]; + for (input, rgb) in input + .chunks(SIMULATION_CHUNK * n) + .zip(output.chunks_mut(SIMULATION_CHUNK * 3)) + { + let count = (input.len() / n).min(rgb.len() / 3); + let cmyk = cmyk.get_mut(..count * 4)?; + self.inner + .simulate_cmyk(simulation, input.get(..count * n)?, manual_scale, cmyk)?; + for (px, colour) in rgb.chunks_exact_mut(3).zip(cmyk.chunks_exact(4)) { + px.fill(f32_to_u8(1.0 - unit(colour.get(k).copied().unwrap_or(0.0)))); + } + } + Some(()) + } + + /// The film of every entry of an Indexed inner space's palette, worked out once for an + /// image's `samples` rather than once per sample; `None` for other spaces, and when there + /// are no more samples than palette entries. + fn palette_films(&self, samples: usize) -> Option> { + let ColorSpaceType::Indexed(indexed) = self.inner.0.as_ref() else { + return None; + }; + if samples <= usize::from(indexed.hival) + 1 { + return None; + } + Some( + (0..=indexed.hival) + .map(|index| self.film(&[f32::from(index)], false)) + .collect(), + ) + } + + /// Turn sRGB pixels, converted from the inner space, into film in place. + fn film_from_srgb(&self, output: &mut [u8]) { + let Some(k) = self.ink.process_index() else { + output.fill(255); + return; + }; + for rgb in output.chunks_exact_mut(3) { + let cmyk = rgb_to_cmyk([u8_to_f32(rgb[0]), u8_to_f32(rgb[1]), u8_to_f32(rgb[2])]); + let film = f32_to_u8(1.0 - cmyk.get(k).copied().unwrap_or(0.0)); + rgb.copy_from_slice(&[film; 3]); + } + } +} + +impl ToRgb for PlateSpace { + fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> { + if self + .film_through_simulation(input, output, manual_scale) + .is_some() + { + return Some(()); + } + if self.inner.separates_through_srgb() { + self.inner.convert_f32(input, output, manual_scale)?; + self.film_from_srgb(output); + return Some(()); + } + if let Some(films) = self.palette_films(input.len()) { + // As `ink_tint` reads an Indexed sample: the index, clamped and rounded. + let hival = films.len().saturating_sub(1) as f32; + for (index, rgb) in input.iter().zip(output.chunks_exact_mut(3)) { + let index = (clamp_component(*index, 0.0, hival) + 0.5) as usize; + rgb.fill(films.get(index).copied().unwrap_or(255)); + } + return Some(()); + } + for (sample, rgb) in input + .chunks_exact(self.samples()) + .zip(output.chunks_exact_mut(3)) + { + rgb.fill(self.film(sample, manual_scale)); + } + Some(()) + } + + // The spaces that take bytes (device and ICC spaces) read them as component × 255. + fn supports_u8(&self) -> bool { + self.inner.supports_u8() + } + + fn convert_u8(&self, input: &[u8], output: &mut [u8]) -> Option<()> { + if self + .simulation + .as_ref() + .is_some_and(|s| self.inner.simulated_by(s)) + { + // Bytes are the components' encoding scaled to 0–255 (an ICC L*a*b* colour's too, + // so they arrive as `manual_scale` components). + let n = self.samples(); + let mut floats = [0.0_f32; SIMULATION_CHUNK * 4]; + let mut done = Some(()); + for (bytes, rgb) in input + .chunks(SIMULATION_CHUNK * n) + .zip(output.chunks_mut(SIMULATION_CHUNK * 3)) + { + let floats = floats.get_mut(..bytes.len())?; + for (f, b) in floats.iter_mut().zip(bytes) { + *f = u8_to_f32(*b); + } + done = done.and(self.film_through_simulation(floats, rgb, true)); + } + if done.is_some() { + return Some(()); + } + } + if self.inner.separates_through_srgb() { + self.inner.convert_u8(input, output)?; + self.film_from_srgb(output); + return Some(()); + } + let mut sample: ColorComponents = SmallVec::new(); + for (bytes, rgb) in input + .chunks_exact(self.samples()) + .zip(output.chunks_exact_mut(3)) + { + sample.clear(); + sample.extend(bytes.iter().copied().map(u8_to_f32)); + rgb.fill(self.film(&sample, false)); + } + Some(()) + } + + fn is_none(&self) -> bool { + self.inner.is_none() + } } static CMYK_TRANSFORM: LazyLock = LazyLock::new(|| { diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index b389b6b3..99e21f7d 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -120,6 +120,14 @@ pub struct InterpreterSettings { /// costs a decode and a resample each for pixels nobody can see. Never set it for a final /// render (tiles, whole pages, print, export). pub preview: bool, + /// PdfCraft patch (35): draw one ink's separation instead of the composite page (see + /// [`Plate`](crate::color::Plate)). Soft masks still draw in colour: a mask's luminosity + /// comes from its group's colours, not from any plate. Patch 32: the plate's `simulation` + /// converts process colour to an output intent's CMYK. + pub plate: Option, + /// PdfCraft patch (35): told about each spot ink a paint uses (colours, images, shadings + /// and uncoloured patterns), whether or not `plate` is set. + pub ink_sink: Option, } impl Default for InterpreterSettings { @@ -142,6 +150,8 @@ impl Default for InterpreterSettings { ocg_overrides: Arc::new(Vec::new()), cancelled: None, preview: false, + plate: None, + ink_sink: None, } } } diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 838ea099..6c75c464 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -1,6 +1,8 @@ -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, InkUse, Plate}; use crate::context::{Context, path_as_rect}; use crate::device::Device; +use crate::interpret::state::PaintData; +use crate::pattern::Pattern; use crate::util::{BezPathExt, Float32Ext}; use crate::{FillRule, Paint, PathDrawMode, StrokeProps}; use kurbo::{BezPath, Cap, Join, PathEl}; @@ -153,6 +155,23 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> context.get().non_stroke_data() }; + // PdfCraft patch (35): report spot inks, and draw separations. + if let Some(sink) = &context.settings.ink_sink { + data.color_space.report_spots(sink); + if let Some(Pattern::Shading(s)) = &data.pattern { + s.shading.color_space.report_spots(sink); + } + } + if let Some(plate) = &context.settings.plate { + let state = &context.get().graphics_state; + let overprint = if is_stroke { + state.overprint_stroke + } else { + state.overprint_fill + }; + return plate_paint(context, data, plate, overprint, state.overprint_mode); + } + if data.color_space.is_pattern() || data.pattern.is_some() { if let Some(mut pattern) = data.pattern { if let Some(tf) = &data.transfer_function { @@ -176,3 +195,39 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> } } } + +/// PdfCraft patch (35): the paint `data` as drawn on `plate`: a colour becomes its film, a +/// shading is converted through the plate, and a paint that leaves the plate as it is (an +/// overprinting paint that doesn't specify the plate's ink, or the `None` colorant) becomes +/// transparent. Transfer functions are left out: they act on device colorants, which the +/// film doesn't model. +fn plate_paint<'a>( + context: &Context<'a>, + data: PaintData<'a>, + plate: &Plate, + overprint: bool, + overprint_mode: u8, +) -> Paint<'a> { + let untouched = || Paint::Color(Color::film(0.0, 0.0)); + if !(data.color_space.is_pattern() || data.pattern.is_some()) { + let color = Color::new(data.color_space, data.color, data.alpha); + return Paint::Color(color.for_plate(plate, overprint, overprint_mode)); + } + let Some(mut pattern) = data.pattern else { + // Pattern was likely invalid, use transparent paint. + return untouched(); + }; + match &mut pattern { + Pattern::Shading(s) => { + match s.shading.color_space.ink_use(&plate.ink) { + InkUse::Nothing => return untouched(), + InkUse::Unspecified if plate.overprint && overprint => return untouched(), + InkUse::Paints | InkUse::Unspecified => {} + } + std::sync::Arc::make_mut(&mut s.shading).for_plate(plate); + } + Pattern::Tiling(t) => t.for_plate(plate, overprint, overprint_mode), + } + pattern.pre_concat_transform(context.root_transform()); + Paint::Pattern(Box::new(pattern)) +} diff --git a/vendor/hayro-interpret/src/interpret/state.rs b/vendor/hayro-interpret/src/interpret/state.rs index 95b773fb..de8f24e2 100644 --- a/vendor/hayro-interpret/src/interpret/state.rs +++ b/vendor/hayro-interpret/src/interpret/state.rs @@ -269,6 +269,12 @@ pub(crate) struct GraphicsState<'a> { pub(crate) soft_mask: Option>, pub(crate) transfer_function: Option, pub(crate) blend_mode: BlendMode, + + // PdfCraft patch (35): the overprint parameters (ISO 32000-2 §8.6.7, `OP`, `op` and `OPM` + // in a graphics state parameter dictionary). They only matter when drawing separations. + pub(crate) overprint_stroke: bool, + pub(crate) overprint_fill: bool, + pub(crate) overprint_mode: u8, } impl Default for GraphicsState<'_> { @@ -286,6 +292,9 @@ impl Default for GraphicsState<'_> { soft_mask: None, transfer_function: None, blend_mode: BlendMode::default(), + overprint_stroke: false, + overprint_fill: false, + overprint_mode: 0, } } } @@ -408,6 +417,21 @@ pub(crate) fn handle_gs_single<'a>( .map(|n| if n == 0.0 { 0.01 } else { n }) .collect(); } + // PdfCraft patch (35): `OP` sets stroking overprint, and non-stroking overprint too when + // the dictionary has no `op` of its own; `OPM` is 0 or 1 (§8.6.7). + "OP" => { + let overprint = dict.get::(key)?; + let state = &mut context.get_mut().graphics_state; + state.overprint_stroke = overprint; + if !dict.contains_key("op") { + state.overprint_fill = overprint; + } + } + "op" => context.get_mut().graphics_state.overprint_fill = dict.get::(key)?, + "OPM" => { + let mode = dict.get::(key)?; + context.get_mut().graphics_state.overprint_mode = u8::from(mode == 1); + } "Type" => {} _ => {} } diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 3d6d2229..b916a381 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -1,7 +1,7 @@ //! PDF patterns. use crate::cache::Cache; -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, Plate}; use crate::context::{Context, InterpreterCache}; use crate::device::Device; use crate::font::Glyph; @@ -147,6 +147,30 @@ pub struct TilingPattern<'a> { nesting_depth: u32, } +impl TilingPattern<'_> { + /// PdfCraft patch (35): an uncoloured pattern draws its cell in the colour it is used + /// with, so on a plate it draws that colour's film. A coloured pattern's cell is separated + /// as it is drawn. + pub(crate) fn for_plate(&mut self, plate: &Plate, overprint: bool, overprint_mode: u8) { + if !self.is_color { + self.stroke_paint = self + .stroke_paint + .for_plate(plate, overprint, overprint_mode); + self.non_stroking_paint = + self.non_stroking_paint + .for_plate(plate, overprint, overprint_mode); + } + let simulation = plate.simulation.as_ref().map(|s| s.digest()); + self.cache_key = hash128(&( + self.cache_key, + &plate.ink, + plate.overprint, + overprint, + simulation, + )); + } +} + impl Debug for TilingPattern<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str("TilingPattern") diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 2e619e47..0c4ad1e7 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -139,6 +139,16 @@ pub struct Shading { } impl Shading { + /// PdfCraft patch (35): the shading drawn as `plate`'s plate, through its simulation + /// (patch 36). + pub(crate) fn for_plate(&mut self, plate: &crate::color::Plate) { + self.color_space = self + .color_space + .plate(plate.ink.clone(), plate.simulation.clone()); + let simulation = plate.simulation.as_ref().map(|s| s.digest()); + self.cache_key = crate::util::hash128(&(self.cache_key, &plate.ink, simulation)); + } + pub(crate) fn new(dict: &Dict<'_>, stream: Option<&Stream<'_>>, cache: &Cache) -> Option { let cache_key = dict.cache_key(); diff --git a/vendor/hayro-interpret/src/soft_mask.rs b/vendor/hayro-interpret/src/soft_mask.rs index 92cab551..657d6bbf 100644 --- a/vendor/hayro-interpret/src/soft_mask.rs +++ b/vendor/hayro-interpret/src/soft_mask.rs @@ -169,7 +169,13 @@ impl<'a> SoftMask<'a> { transfer_function, bbox: context.bbox(), interpreter_cache: context.interpreter_cache.clone(), - settings: context.settings.clone(), + // PdfCraft patch (35): a mask's luminosity comes from its group's colours, never + // from a plate, and the inks it uses are never printed. + settings: InterpreterSettings { + plate: None, + ink_sink: None, + ..context.settings.clone() + }, xref: context.xref, background, parent_resources, diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index d7d19dce..6a846148 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,5 +1,5 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; -use crate::color::{ColorComponents, ColorSpace, ToRgb}; +use crate::color::{ColorComponents, ColorSpace, InkUse, Plate, ToRgb}; use crate::context::Context; use crate::device::Device; use crate::function::{Function, interpolate}; @@ -227,6 +227,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; @@ -308,9 +339,22 @@ pub(crate) struct ImageXObject<'a> { stream: Stream<'a>, transfer_function: Option, warning_sink: WarningSinkFn, + /// PdfCraft patch (35): the plate the image is drawn on, if any. + plate: Option, } impl<'a> ImageXObject<'a> { + /// PdfCraft patch (35): the image drawn on `plate`. Not cached (the cache holds colour + /// decodes), and without a transfer function (see `get_paint`). + fn for_plate(&self, plate: &Plate) -> Self { + Self { + plate: Some(plate.clone()), + decoded_cache: None, + transfer_function: None, + ..self.clone() + } + } + pub(crate) fn new( stream: &Stream<'a>, resolve_cs: impl FnOnce(&Name<'_>) -> Option, @@ -371,6 +415,7 @@ impl<'a> ImageXObject<'a> { stream: stream.clone(), is_mask, is_stencil_mask, + plate: None, }) } @@ -594,6 +639,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 }; From 7764afb2a780a1e404d9db64d83b5bb2f36f2282 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:53:37 +0000 Subject: [PATCH 03/11] vendor: convert plate-mode CMYK through a CmykConversion trait CmykSimulation converts process colour to an output intent's CMYK through a color::CmykConversion: sRGB colours, and CIE XYZ relative to the D50 PCS white (color::PCS_WHITE) for Lab and ICCBased colours. CmykSimulation::new keeps the moxcms conversion from pdfcraft #2770, which takes XYZ on through its ProPhoto hub; with_conversion takes a caller's, so a crate can separate with its own colour management. crates/pdf/tests/plates.rs adapts #2770's plate tests to the raster path: process, spot, DeviceN, All/None, ICC CMYK, gray and RGB colours, images, shadings, uncoloured patterns, soft masks, OP/op/OPM, the ink sink, output-intent simulation with #2770's synthetic ICC profiles, and a caller's conversion. The ledger and NOTICE name the pull request. Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> --- NOTICE | 4 + crates/pdf/tests/plates.rs | 448 ++++++++++++++++++++ crates/pdf/tests/support/synthetic_icc.rs | 227 ++++++++++ vendor/README.craft-reader.md | 8 + vendor/hayro-interpret/SOURCE.json | 4 +- vendor/hayro-interpret/src/color.rs | 260 ++++++++---- vendor/hayro-interpret/src/interpret/mod.rs | 2 +- 7 files changed, 874 insertions(+), 79 deletions(-) create mode 100644 crates/pdf/tests/plates.rs create mode 100644 crates/pdf/tests/support/synthetic_icc.rs diff --git a/NOTICE b/NOTICE index d9e06ba0..29578b98 100644 --- a/NOTICE +++ b/NOTICE @@ -101,6 +101,10 @@ Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 These patched sources are transferred from PdfCraft revision ec0a965074f376a5bf610435948e4e5184bf3299 (https://github.com/storytold/pdfcraft). See vendor/README.craft-reader.md and each crate's SOURCE.json for provenance and local changes. +The hayro-interpret plate mode (separations, overprint and output-intent simulation) is ported +from PdfCraft pull request #2770 by Guillem Hermida, commit +6fcf2a4aca29e943e28cb69017ff8f87d00656c6 (https://github.com/storytold/pdfcraft/pull/2770), +MIT OR Apache-2.0; crates/pdf/tests/plates.rs and tests/support/synthetic_icc.rs adapt its tests. The reader retains the existing Foxit/PDFium standard-14 font assets (BSD-3-Clause), Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Clause). Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs new file mode 100644 index 00000000..8588e3cf --- /dev/null +++ b/crates/pdf/tests/plates.rs @@ -0,0 +1,448 @@ +//! Plate mode through the raster path: each ink's separation drawn as a gray film. +//! Adapted from PdfCraft #2770 by Guillem Hermida (`separations::tests` in pdfcraft-render at +//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0). +#![cfg(feature = "raster")] + +#[path = "support/synthetic_icc.rs"] +mod synthetic_icc; + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::interpret::color::{CmykConversion, CmykSimulation, Ink, InkSinkFn, NoSimulation, PCS_WHITE, Plate}; +use craft_pdf::{ + open_slice, + raster::{self, Options, RenderCache}, +}; +use std::sync::{Arc, Mutex}; + +/// A 40 × 40 pt page with `content` and `resources`, and `objects` numbered from 5 (each a +/// whole object body: a dictionary, or a dictionary and its stream). +fn page_pdf(content: &str, resources: &str, objects: &[Vec]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + out.extend_from_slice( + format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(), + ); + out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + for (i, body) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b" endobj\n"); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +/// A stream object body with `dict` entries and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +fn render(pdf: &[u8], settings: &InterpreterSettings) -> Vec { + let doc = open_slice(pdf, None).unwrap(); + let options = Options { background: [255; 4], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), settings, &options).unwrap(); + assert_eq!((raster.width, raster.height), (40, 40)); + raster.bytes().to_vec() +} + +/// One film per ink, as RGBA8 over white. +struct Plates(Vec>); + +impl Plates { + /// Each ink's tint at device pixel (x, y), in whole percent. + fn at(&self, x: usize, y: usize) -> Vec { + self.0 + .iter() + .map(|film| { + let px = &film[(y * 40 + x) * 4..][..4]; + assert_eq!((px[0], px[3]), (px[2], 255), "a film is opaque gray"); + (f32::from(255 - px[0]) * 100.0 / 255.0).round() as u32 + }) + .collect() + } +} + +fn plates(pdf: &[u8], inks: &[Ink], overprint: bool, simulation: Option<&CmykSimulation>) -> Plates { + Plates( + inks.iter() + .map(|ink| { + let plate = Plate { ink: ink.clone(), overprint, simulation: simulation.cloned() }; + render(pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() }) + }) + .collect(), + ) +} + +fn spot(name: &str) -> Ink { + Ink::Spot(name.into()) +} + +/// The four process plates, then `spots`. +fn inks(spots: &[&str]) -> Vec { + Ink::PROCESS.into_iter().chain(spots.iter().map(|s| spot(s))).collect() +} + +/// The process plates and Gold, overprint honoured. +fn separate(pdf: &[u8]) -> Plates { + plates(pdf, &inks(&["Gold"]), true, None) +} + +/// A spot colour whose alternate is a yellowish CMYK. +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]"; + +fn gold_resource() -> String { + format!("/ColorSpace << /CS0 {GOLD} >>") +} + +const CGATS: &[u8] = include_bytes!("../../../vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc"); + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn device_cmyk_puts_each_component_on_its_own_plate() { + let s = plates(&page_pdf("0.2 0.4 0.6 0.8 k 0 0 40 40 re f", "", &[]), &inks(&[]), true, None); + assert_eq!(s.at(20, 20), [20, 40, 60, 80]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_spot_colour_paints_its_own_plate_and_knocks_out_the_process_plates() { + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /CS0 cs 0.5 scn 10 10 20 20 re f", &gold_resource(), &[])); + assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "knocked out under the spot colour"); + assert_eq!(s.at(2, 2), [0, 0, 0, 100, 0]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn overprinting_keeps_the_plates_a_colour_does_not_specify() { + let resources = format!("{} /ExtGState << /GS1 << /op true >> >>", gold_resource()); + let pdf = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS1 gs /CS0 cs 0.5 scn 10 10 20 20 re f", &resources, &[]); + assert_eq!(separate(&pdf).at(20, 20), [0, 0, 0, 100, 50], "overprinted"); + assert_eq!(plates(&pdf, &inks(&["Gold"]), false, None).at(20, 20), [0, 0, 0, 0, 50], "overprint simulation off: knocked out"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn op_sets_stroke_overprint_and_fill_overprint_too_without_op() { + let resources = format!("{} /ExtGState << /GS1 << /OP true >> /GS2 << /OP true /op false >> >>", gold_resource()); + let fill = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f /{gs} gs /CS0 cs 1 scn 10 10 20 20 re f"), &resources, &[]); + assert_eq!(separate(&fill("GS1")).at(20, 20)[3], 100, "OP alone also sets op"); + assert_eq!(separate(&fill("GS2")).at(20, 20)[3], 0, "op false keeps fills knocking out"); + let stroke = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS2 gs /CS0 CS 1 SCN 8 w 0 20 m 40 20 l S", &resources, &[]); + assert_eq!(separate(&stroke).at(20, 20), [0, 0, 0, 100, 100], "strokes overprint with OP"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn nonzero_overprint_mode_leaves_zero_device_cmyk_components() { + let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> /M0 << /OP true /OPM 0 >> >>"; + let pdf = |gs: &str| page_pdf(&format!("1 0 0 0 k 0 0 40 40 re f /{gs} gs 0 1 0 0 k 10 10 20 20 re f"), resources, &[]); + let process = inks(&[]); + assert_eq!(plates(&pdf("M1"), &process, true, None).at(20, 20), [100, 100, 0, 0], "OPM 1: the zero cyan leaves the plate"); + assert_eq!(plates(&pdf("M0"), &process, true, None).at(20, 20), [0, 100, 0, 0], "OPM 0: zero components paint"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn gray_and_rgb_are_process_colour_and_never_take_the_nonzero_overprint_mode() { + let process = inks(&[]); + let gray = page_pdf("1 0 0 0 k 0 0 40 40 re f /M1 gs 0.25 g 10 10 20 20 re f", "/ExtGState << /M1 << /OP true /OPM 1 >> >>", &[]); + assert_eq!(plates(&gray, &process, true, None).at(20, 20), [0, 0, 0, 75], "gray is black only, and knocks out cyan"); + let s = plates(&page_pdf("1 0 0 rg 0 0 20 40 re f 0.2 0.2 0.2 rg 20 0 20 40 re f", "", &[]), &process, true, None); + assert_eq!(s.at(10, 20), [0, 100, 100, 0], "red"); + assert_eq!(s.at(30, 20), [0, 0, 0, 80], "a neutral gray goes to black (10.4.2.4)"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn devicen_components_go_to_the_plates_they_name() { + let resources = "/ColorSpace << /CS0 [/DeviceN [/Cyan /Silver /None] /DeviceCMYK 5 0 R] >>"; + let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ pop 0.5 mul 0 0 }"); + let s = plates(&page_pdf("/CS0 cs 0.3 0.7 1 scn 0 0 40 40 re f", resources, &[function]), &inks(&["Silver"]), true, None); + assert_eq!(s.at(20, 20), [30, 0, 0, 0, 70]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_all_colorant_paints_every_plate_and_none_paints_nothing() { + let gray = "/DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let resources = format!("/ColorSpace << /CS0 {GOLD} /Reg [/Separation /All {gray}] /No [/Separation /None {gray}] >>"); + let content = "/CS0 cs 0.2 scn 0 0 40 40 re f /Reg cs 1 scn 0 0 20 40 re f /No cs 1 scn 20 0 20 40 re f"; + let s = separate(&page_pdf(content, &resources, &[])); + assert_eq!(s.at(10, 20), [100, 100, 100, 100, 100], "registration colour"); + assert_eq!(s.at(30, 20), [0, 0, 0, 0, 20], "None leaves every plate as it is"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn icc_based_cmyk_is_separated_as_its_components() { + let pdf = page_pdf("/CS0 cs 0.1 0.3 0.5 0.7 scn 0 0 40 40 re f", "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", &[stream("/N 4", CGATS)]); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [10, 30, 50, 70]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn images_are_separated_sample_by_sample() { + let cmyk = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[255, 0, 0, 0, 0, 0, 0, 128]); + let s = plates(&page_pdf("q 40 0 0 40 0 0 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &[cmyk]), &inks(&[]), true, None); + assert_eq!(s.at(10, 20), [100, 0, 0, 0]); + assert_eq!(s.at(30, 20), [0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_spot_colour_image_overprints_like_any_paint() { + let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[128]); + let resources = "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true >> >>"; + let pdf = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} q 20 0 0 20 10 10 cm /Im1 Do Q"), resources, std::slice::from_ref(&image)); + assert_eq!(separate(&pdf("/GS1 gs")).at(20, 20), [0, 0, 0, 100, 50]); + assert_eq!(separate(&pdf("")).at(20, 20), [0, 0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shadings_are_separated_through_their_colour_space() { + let shading = format!( + "<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 40 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>" + ); + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /Sh1 sh", "/Shading << /Sh1 5 0 R >>", &[shading.into_bytes()])); + let (left, right) = (s.at(4, 20), s.at(36, 20)); + assert!(left[4] < 20 && right[4] > 80, "a gradient on the spot plate: {left:?} {right:?}"); + assert_eq!((left[3], right[3]), (0, 0), "the shading knocks out the black under it"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn an_uncoloured_pattern_paints_the_film_of_the_colour_it_is_used_with() { + let cell = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", b"0 0 10 10 re f"); + let resources = format!("/ColorSpace << /PCS [/Pattern {GOLD}] >> /Pattern << /P1 5 0 R >>"); + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /PCS cs 0.5 /P1 scn 0 0 40 40 re f", &resources, &[cell])); + assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "the pattern's spot colour, knocking out black"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn soft_masks_keep_the_luminosity_of_their_own_colours() { + // A mask drawn in blue lets about a tenth of the black through. Drawn as the black + // plate's film, blue (no black ink) would read as white and let all of it through. + let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceRGB >>", b"0 0 1 rg 0 0 40 40 re f"); + let resources = "/ExtGState << /SM << /SMask << /S /Luminosity /G 5 0 R >> >> >>"; + let s = plates(&page_pdf("/SM gs 0 0 0 1 k 0 0 40 40 re f", resources, &[group]), &[Ink::Black], true, None); + let black = s.at(20, 20)[0]; + assert!((1..30).contains(&black), "black through a blue luminosity mask: {black}%"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_ink_sink_reports_spot_inks_of_patterns_and_images_but_not_of_soft_masks() { + let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[255]); + let cell = stream( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << /ColorSpace << /S [/Separation /Silver /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0.5] /N 1 >>] >> >>", + b"/S cs 1 scn 0 0 5 5 re f", + ); + let mask_group = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /ColorSpace << /M [/Separation /MaskOnly /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>", + b"/M cs 0.5 scn 0 0 40 40 re f", + ); + let resources = "/XObject << /Im1 5 0 R >> /Pattern << /P1 6 0 R >> /ExtGState << /SM << /SMask << /S /Luminosity /G 7 0 R >> >> >>"; + let content = "/Pattern cs /P1 scn 0 0 20 20 re f q 10 0 0 10 25 25 cm /Im1 Do Q /SM gs 0 g 0 0 40 40 re f"; + // Each spot ink with its appearance at 100%. + type Seen = Vec<(Ink, [u8; 3])>; + let seen: Arc> = Arc::default(); + let log = seen.clone(); + let sink: InkSinkFn = Arc::new(move |ink: &Ink, appearance: &dyn Fn(f32) -> [u8; 3]| { + let mut log = log.lock().unwrap(); + if !log.iter().any(|(seen, _)| seen == ink) { + log.push((ink.clone(), appearance(1.0))); + } + }); + render(&page_pdf(content, resources, &[image, cell, mask_group]), &InterpreterSettings { ink_sink: Some(sink), ..Default::default() }); + let seen = seen.lock().unwrap(); + let names: Vec<&str> = seen.iter().map(|(ink, _)| ink.name()).collect(); + assert_eq!(names, ["Silver", "Gold"], "in the order first painted; a mask's ink doesn't print"); + assert_eq!(seen[0].1, [128, 128, 128], "Silver at 100% is its alternate colour"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn malformed_colour_spaces_and_overprint_parameters_draw_what_they_can() { + let resources = "/ColorSpace << /A [/DeviceN [] /DeviceGray 0] /B [/Separation] /C [/Indexed /DeviceCMYK 300 <00>] >> /ExtGState << /G << /OP 7 /OPM /X /op (y) >> >>"; + let pdf = page_pdf("/G gs /A cs 1 scn 0 0 9 9 re f /B cs 1 scn /C cs 255 scn 0 0 40 40 re f", resources, &[]); + plates(&pdf, &inks(&["Gold"]), true, None); +} + +// Separating through an output intent's CMYK profile (vendored patch 36). + +const PRESS: &str = "PdfCraft synthetic press"; + +fn press_simulation() -> CmykSimulation { + CmykSimulation::new(&synthetic_icc::cmyk_profile(PRESS, true)).unwrap() +} + +/// What the synthetic press prints for CIE L*a*b*, in whole percent. +fn press(l: f64, a: f64, b: f64) -> Vec { + synthetic_icc::btoa(l, a, b).iter().map(|v| (v * 100.0).round() as u32).collect() +} + +/// Whether every ink is within `tolerance` percent of `expected`. +fn near(got: &[u32], expected: &[u32], tolerance: u32) -> bool { + got.len() == expected.len() && got.iter().zip(expected).all(|(g, e)| g.abs_diff(*e) <= tolerance) +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn rgb_separates_through_the_simulated_cmyk_profile() { + let simulation = press_simulation(); + assert_eq!(simulation.description(), Some(PRESS)); + assert!(!simulation.perceptual()); + let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]); + // The press prints a neutral with equal inks, where the device formulas put it on black + // alone. + let gray = press(synthetic_icc::srgb_gray_lightness(0.5), 0.0, 0.0); + let got = plates(&pdf, &inks(&[]), true, Some(&simulation)).at(20, 20); + assert!(near(&got, &gray, 2), "{got:?}, the press prints {gray:?}"); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn only_cmyk_profiles_with_pcs_to_device_tables_simulate() { + let whole = synthetic_icc::cmyk_profile(PRESS, true); + assert_eq!(CmykSimulation::new(&synthetic_icc::gray_profile("Gray")).err(), Some(NoSimulation::NotCmyk)); + assert_eq!(CmykSimulation::new(&synthetic_icc::cmyk_profile("Proofing only", false)).err(), Some(NoSimulation::NoPcsToDevice)); + assert_eq!(CmykSimulation::new(b"not a profile").err(), Some(NoSimulation::Unreadable)); + assert_eq!(CmykSimulation::new(&whole[..300]).err(), Some(NoSimulation::Unreadable)); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn cmyk_in_the_simulated_profile_passes_through_and_other_cmyk_converts() { + let profile = synthetic_icc::cmyk_profile(PRESS, true); + let simulation = CmykSimulation::new(&profile).unwrap(); + // Object 5 is the press profile, 6 another CMYK profile. + let resources = "/ColorSpace << /Same [/ICCBased 5 0 R] /Other [/ICCBased 6 0 R] >>"; + let content = "0.2 0.4 0.6 0.8 k 0 0 10 40 re f /Same cs 0.1 0.3 0.5 0.7 scn 10 0 10 40 re f /Other cs 0 0 0 1 scn 30 0 10 40 re f"; + let pdf = page_pdf(content, resources, &[stream("/N 4", &profile), stream("/N 4", CGATS)]); + let s = plates(&pdf, &inks(&[]), true, Some(&simulation)); + assert_eq!(s.at(5, 20), [20, 40, 60, 80], "DeviceCMYK is the press's own CMYK"); + assert_eq!(s.at(15, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile"); + let other = s.at(35, 20); + assert!(other.iter().all(|v| *v > 70), "black alone in another profile is a four-ink black on this press: {other:?}"); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(35, 20), [0, 0, 0, 100], "without a simulation ICC CMYK is taken as it is"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn device_gray_stays_on_the_black_plate_under_a_simulation() { + let s = plates(&page_pdf("0.25 g 0 0 40 40 re f", "", &[]), &inks(&[]), true, Some(&press_simulation())); + assert_eq!(s.at(20, 20), [0, 0, 0, 75]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn cie_and_icc_colours_convert_through_the_profile_relative_to_their_white() { + let resources = + "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Gray [/ICCBased 5 0 R] >>"; + let content = "/D50 cs 50 20 -10 scn 0 0 10 40 re f /D65 cs 100 0 0 scn 10 0 10 40 re f /Gray cs 0.5 scn 20 0 10 40 re f"; + let pdf = page_pdf(content, resources, &[stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2"))]); + let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation())); + assert!(near(&s.at(5, 20), &press(50.0, 20.0, -10.0), 2), "Lab: {:?}", s.at(5, 20)); + assert_eq!(s.at(15, 20), [0, 0, 0, 0], "a Lab space's own white prints as paper, whatever its white point"); + let lightness = 116.0 * 0.5_f64.powf(2.2).cbrt() - 16.0; + assert!(near(&s.at(25, 20), &press(lightness, 0.0, 0.0), 2), "ICC gray: {:?}", s.at(25, 20)); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn images_convert_through_the_simulation_sample_by_sample() { + let rgb = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB", &[128, 128, 128, 255, 255, 255]); + // More samples than palette entries: the palette is separated once. + let indexed = stream( + "/Type /XObject /Subtype /Image /Width 4 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 <808080FFFFFF>]", + &[0, 0, 1, 1], + ); + let content = "q 40 0 0 20 0 20 cm /Im1 Do Q q 40 0 0 20 0 0 cm /Im2 Do Q"; + let pdf = page_pdf(content, "/XObject << /Im1 5 0 R /Im2 6 0 R >>", &[rgb, indexed]); + let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation())); + let gray = press(synthetic_icc::srgb_gray_lightness(128.0 / 255.0), 0.0, 0.0); + for y in [10, 30] { + assert!(near(&s.at(10, y), &gray, 2), "y = {y}: {:?}, the press prints {gray:?}", s.at(10, y)); + assert_eq!(s.at(30, y), [0, 0, 0, 0], "y = {y}: white prints no ink"); + } +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_simulation_shows_its_cmyk_through_its_own_profile() { + // The inks the press prints sRGB gray 128 with show as that gray: its two tables invert + // each other. + let ink = (255.0 * (1.0 - synthetic_icc::srgb_gray_lightness(128.0 / 255.0) / 100.0)).round() as u8; + let mut shown = [0; 3]; + press_simulation().cmyk_to_srgb8(&[ink; 4], &mut shown).unwrap(); + assert!(shown.iter().all(|c| c.abs_diff(128) <= 4), "{shown:?}"); + assert!(press_simulation().cmyk_to_srgb8(&[0; 4], &mut [0; 4]).is_none(), "lengths disagree"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn mutated_profiles_simulate_or_are_refused_without_panicking() { + // Bytes through the profile's header, tag table and tables changed in turn. Making a + // simulation takes a third of a second in a debug build, so the bytes are sampled. + let whole = synthetic_icc::cmyk_profile(PRESS, true); + let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]); + let positions = (0..whole.len().min(256)).step_by(16).chain((256..whole.len()).step_by(9973)); + for (n, at_byte) in positions.enumerate() { + let mut bent = whole.clone(); + bent[at_byte] = [0x00, 0x7F, 0xFF][n % 3]; + if let Ok(simulation) = CmykSimulation::new(&bent) { + let plate = Plate { ink: Ink::Cyan, overprint: true, simulation: Some(simulation) }; + render(&pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() }); + } + } +} + +/// A caller's conversion: sRGB prints as 10/20/30/40%, XYZ as 50/60/70/80%, and the XYZ it is +/// given is kept. +struct Fixed(Arc>>); + +impl CmykConversion for Fixed { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + (rgb.len() / 3 == cmyk.len() / 4).then(|| cmyk.as_chunks_mut::<4>().0.fill([0.1, 0.2, 0.3, 0.4])) + } + + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + self.0.lock().unwrap().extend(xyz.as_chunks::<3>().0.iter().copied()); + cmyk.as_chunks_mut::<4>().0.fill([0.5, 0.6, 0.7, 0.8]); + Some(()) + } + + fn description(&self) -> Option<&str> { + Some("fixed") + } +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_callers_cmyk_conversion_separates_process_colour() { + let profile = synthetic_icc::cmyk_profile(PRESS, true); + let xyz = Arc::default(); + let simulation = CmykSimulation::with_conversion(&profile, Fixed(Arc::clone(&xyz))); + assert_eq!(simulation.description(), Some("fixed")); + assert!(simulation.cmyk_to_srgb8(&[0; 4], &mut [0; 3]).is_none(), "no way to show CMYK"); + let resources = "/ColorSpace << /Lab [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Same [/ICCBased 5 0 R] >>"; + let content = "1 0 0 rg 0 0 10 40 re f /Lab cs 100 0 0 scn 10 0 10 40 re f 0.2 0.4 0.6 0.8 k 20 0 10 40 re f \ + /Same cs 0.1 0.3 0.5 0.7 scn 30 0 10 40 re f"; + let s = plates(&page_pdf(content, resources, &[stream("/N 4", &profile)]), &inks(&[]), true, Some(&simulation)); + assert_eq!(s.at(5, 20), [10, 20, 30, 40], "DeviceRGB through from_srgb"); + assert_eq!(s.at(15, 20), [50, 60, 70, 80], "Lab through from_pcs_xyz"); + assert_eq!(s.at(25, 20), [20, 40, 60, 80], "DeviceCMYK as it is"); + assert_eq!(s.at(35, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile as it is"); + let xyz = xyz.lock().unwrap(); + assert!(!xyz.is_empty()); + for c in xyz.iter() { + assert!(c.iter().zip(PCS_WHITE).all(|(v, w)| (v - w).abs() < 1e-3), "a Lab white arrives as the PCS white: {c:?}"); + } +} diff --git a/crates/pdf/tests/support/synthetic_icc.rs b/crates/pdf/tests/support/synthetic_icc.rs new file mode 100644 index 00000000..f81ceca0 --- /dev/null +++ b/crates/pdf/tests/support/synthetic_icc.rs @@ -0,0 +1,227 @@ +//! Synthetic ICC profiles for the plate-mode tests, written byte by byte from ICC.1 (version +//! 2.1 profiles with `lut16Type` tables and the legacy 16-bit PCS L*a*b* encoding). They are +//! contributor-original and generated when a test runs; no profile file is committed. +//! +//! The CMYK printing condition is made up so that its conversions can be worked out by hand: +//! +//! - AToB (CMYK → PCS L*a*b*): L* = 100 · (1 − (c + m + y + k) / 4), a* = 60 · (m − c), +//! b* = 60 · (y − (c + m) / 2). It is affine, so a two-point grid holds it exactly. +//! - BToA (PCS → CMYK), its inverse with equal inks along the neutral axis: with +//! t = 1 − L* / 100, c = t − a* / 120, m = t + a* / 120, y = t + b* / 60, k = t − b* / 60, each +//! clamped to 0..1. A neutral of L* 50 prints as 50% of each ink, where the ISO 32000-2 +//! §10.4.2.4 device formulas would print it on black alone. +//! +//! From PdfCraft #2770 by Guillem Hermida (`crates/render/tests/support/synthetic_icc.rs` at +//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0). Used by `tests/plates.rs` +//! through `#[path]`. + +#![allow(dead_code)] + +/// Grid points per input of the BToA table: fine enough that colours a little off the neutral +/// axis interpolate between unclamped grid points. +const BTOA_GRID: usize = 17; + +/// What the synthetic printing condition prints for PCS L*a*b*, each ink `0.0..=1.0`. +pub fn btoa(l: f64, a: f64, b: f64) -> [f64; 4] { + let t = 1.0 - l / 100.0; + [t - a / 120.0, t + a / 120.0, t + b / 60.0, t - b / 60.0].map(|v| v.clamp(0.0, 1.0)) +} + +/// The PCS L*a*b* the synthetic printing condition gives an ink combination. +pub fn atob([c, m, y, k]: [f64; 4]) -> [f64; 3] { + [100.0 * (1.0 - (c + m + y + k) / 4.0), 60.0 * (m - c), 60.0 * (y - (c + m) / 2.0)] +} + +/// The CIE L* of an sRGB gray `v` (`0.0..=1.0`); a neutral's a* and b* are 0. +pub fn srgb_gray_lightness(v: f64) -> f64 { + let linear = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let f = if linear > 216.0 / 24389.0 { linear.cbrt() } else { (24389.0 / 27.0 * linear + 16.0) / 116.0 }; + 116.0 * f - 16.0 +} + +/// A CMYK output profile (`prtr`) of the synthetic printing condition, described as +/// `description`. With `btoa` false it has no PCS-to-device tables (and so can't be printed to). +pub fn cmyk_profile(description: &str, btoa_tables: bool) -> Vec { + let atob_table = lut16(4, 3, 2, false, |node| { + let cmyk = [node[0], node[1], node[2], node[3]]; + encode_lab(atob(cmyk)) + }); + let mut tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"desc", text_description(description)), + (b"cprt", text(b"No copyright, synthetic test data")), + (b"wtpt", xyz(D50)), + (b"A2B0", atob_table.clone()), + (b"A2B1", atob_table.clone()), + (b"A2B2", atob_table), + ]; + if btoa_tables { + let btoa_table = lut16(3, 4, BTOA_GRID, true, |node| { + let [l, a, b] = grid_lab([node[0], node[1], node[2]]); + btoa(l, a, b).map(|v| v * 65535.0).to_vec() + }); + tags.push((b"B2A0", btoa_table.clone())); + tags.push((b"B2A1", btoa_table.clone())); + tags.push((b"B2A2", btoa_table)); + // Everything is in gamut. + tags.push((b"gamt", lut16(3, 1, 2, false, |_| vec![0.0]))); + } + profile(b"prtr", b"CMYK", b"Lab ", &tags) +} + +/// A gray display profile (gamma 2.2), described as `description`: an output intent that is +/// not CMYK. +pub fn gray_profile(description: &str) -> Vec { + // curveType with one entry: a gamma as u8Fixed8Number (2.2 ≈ 0x0233). + let mut curve = b"curv\0\0\0\0".to_vec(); + curve.extend_from_slice(&1_u32.to_be_bytes()); + curve.extend_from_slice(&0x0233_u16.to_be_bytes()); + let tags: Vec<(&[u8; 4], Vec)> = + vec![(b"desc", text_description(description)), (b"cprt", text(b"No copyright, synthetic test data")), (b"wtpt", xyz(D50)), (b"kTRC", curve)]; + profile(b"mntr", b"GRAY", b"XYZ ", &tags) +} + +/// The D50 illuminant of the PCS. +const D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// PCS L*a*b* in the legacy 16-bit encoding (ICC.1 Annex A), as `0.0..=65535.0` table values: +/// L* 100 is 0xFF00 and a* or b* 0 is 0x8000. +fn encode_lab([l, a, b]: [f64; 3]) -> Vec { + vec![l * 65280.0 / 100.0, (a + 128.0) * 256.0, (b + 128.0) * 256.0].into_iter().map(|v| v.clamp(0.0, 65535.0)).collect() +} + +/// A BToA grid point's PCS L*a*b*: `node` holds its inputs as `0.0..=1.0`. The L* input +/// table has stretched L* 100 (0xFF00) onto the last grid point, so white prints no ink; a* +/// and b* are in the legacy encoding. +fn grid_lab(node: [f64; 3]) -> [f64; 3] { + let [l, a, b] = node; + [l * 100.0, a * 65535.0 / 256.0 - 128.0, b * 65535.0 / 256.0 - 128.0] +} + +/// A `lut16Type` with `inputs` × `outputs` channels, `grid` points per input, identity +/// matrix and output tables, and the grid filled by `value` (each grid point's inputs as +/// `0.0..=1.0`, returning its outputs as `0.0..=65535.0`). The input tables are the identity, +/// except that `stretch_lightness` scales the first input by 0xFFFF / 0xFF00 (see [`grid_lab`]). +fn lut16(inputs: usize, outputs: usize, grid: usize, stretch_lightness: bool, value: impl Fn(&[f64]) -> Vec) -> Vec { + const ENTRIES: usize = 257; + let mut out = b"mft2\0\0\0\0".to_vec(); + out.extend_from_slice(&[inputs as u8, outputs as u8, grid as u8, 0]); + for k in 0_usize..9 { + let one = if k.is_multiple_of(4) { 0x0001_0000_i32 } else { 0 }; + out.extend_from_slice(&one.to_be_bytes()); + } + out.extend_from_slice(&(ENTRIES as u16).to_be_bytes()); + out.extend_from_slice(&2_u16.to_be_bytes()); + for channel in 0..inputs { + for e in 0..ENTRIES { + let x = e as f64 / (ENTRIES - 1) as f64; + let y = if stretch_lightness && channel == 0 { (x * 65535.0 / 65280.0).min(1.0) } else { x }; + out.extend_from_slice(&((y * 65535.0).round() as u16).to_be_bytes()); + } + } + // The first input varies slowest. + let points = grid.pow(inputs as u32); + for index in 0..points { + let mut node = vec![0.0; inputs]; + let mut rest = index; + for i in (0..inputs).rev() { + node[i] = (rest % grid) as f64 / (grid - 1) as f64; + rest /= grid; + } + let values = value(&node); + assert_eq!(values.len(), outputs); + for v in values { + out.extend_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_be_bytes()); + } + } + for _ in 0..outputs { + out.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + out +} + +/// A `textDescriptionType` (ICC.1:2001, the version 2 `desc` tag) with an ASCII description. +fn text_description(description: &str) -> Vec { + let ascii: Vec = description.bytes().filter(u8::is_ascii).collect(); + let mut out = b"desc\0\0\0\0".to_vec(); + out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes()); + out.extend_from_slice(&ascii); + out.push(0); + out.extend_from_slice(&[0; 4]); // Unicode language code + out.extend_from_slice(&[0; 4]); // Unicode count + out.extend_from_slice(&[0; 2]); // ScriptCode code + out.push(0); // ScriptCode count + out.extend_from_slice(&[0; 67]); + out +} + +/// A `textType`. +fn text(ascii: &[u8]) -> Vec { + let mut out = b"text\0\0\0\0".to_vec(); + out.extend_from_slice(ascii); + out.push(0); + out +} + +fn s15_fixed16(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +/// An `XYZType` with one value. +fn xyz(v: [f64; 3]) -> Vec { + let mut out = b"XYZ \0\0\0\0".to_vec(); + for c in v { + out.extend_from_slice(&s15_fixed16(c)); + } + out +} + +/// A whole version 2.1 profile: the 128-byte header, the tag table, then each tag's data, +/// 4-byte aligned. Tags with equal data share it. +fn profile(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[(&[u8; 4], Vec)]) -> Vec { + let table_end = 128 + 4 + 12 * tags.len(); + let mut data: Vec = Vec::new(); + let mut entries: Vec<(&[u8; 4], usize, usize)> = Vec::new(); + for (signature, body) in tags { + let shared = entries.iter().zip(tags).find(|(_, (_, b))| b == body).map(|((_, offset, len), _)| (*offset, *len)); + let (offset, len) = match shared { + Some(place) => place, + None => { + while !data.len().is_multiple_of(4) { + data.push(0); + } + let offset = table_end + data.len(); + data.extend_from_slice(body); + (offset, body.len()) + } + }; + entries.push((signature, offset, len)); + } + while !data.len().is_multiple_of(4) { + data.push(0); + } + let size = table_end + data.len(); + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&(size as u32).to_be_bytes()); + out.extend_from_slice(&[0; 4]); // preferred CMM + out.extend_from_slice(&0x0210_0000_u32.to_be_bytes()); + out.extend_from_slice(class); + out.extend_from_slice(space); + out.extend_from_slice(pcs); + for v in [2026_u16, 10, 16, 0, 0, 0] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.extend_from_slice(b"acsp"); + out.extend_from_slice(&[0; 4 + 4 + 4 + 4 + 8 + 4]); // platform, flags, manufacturer, model, attributes, intent + for c in D50 { + out.extend_from_slice(&s15_fixed16(c)); + } + out.extend_from_slice(&[0; 4 + 16 + 28]); // creator, ID, reserved + out.extend_from_slice(&(tags.len() as u32).to_be_bytes()); + for (signature, offset, len) in &entries { + out.extend_from_slice(*signature); + out.extend_from_slice(&(*offset as u32).to_be_bytes()); + out.extend_from_slice(&(*len as u32).to_be_bytes()); + } + out.extend_from_slice(&data); + out +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index e77d422e..ca41a2dd 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -39,6 +39,14 @@ same MIT OR Apache-2.0 licence. Code comments keep the pull request's patch numb |---|---|---|---|---| | [#2770](https://github.com/storytold/pdfcraft/pull/2770) | Guillem Hermida (guillehr2) | [6fcf2a4](https://github.com/guillehr2/pdfcraft/commit/6fcf2a4aca29e943e28cb69017ff8f87d00656c6) | hayro-interpret `color.rs`, `interpret/mod.rs`, `interpret/path.rs`, `interpret/state.rs`, `pattern.rs`, `shading.rs`, `soft_mask.rs`, `x_object.rs` | Patches 35 and 36, plate mode: `InterpreterSettings::plate` draws one ink's separation as a gray film (`color::Plate`, `ColorSpace::ink_use`/`ink_tint`); the graphics state reads `OP`, `op` and `OPM` and an overprinting paint that doesn't specify the plate's ink leaves the plate as it is (ISO 32000-2 §8.6.7); `InterpreterSettings::ink_sink` reports spot inks; `color::CmykSimulation` converts process colour to an output intent's CMYK. Only the vendored interpreter part is ported; PdfCraft's `separations.rs` and Output Preview UI are not | +The output-intent conversion of patch 36 goes through a `color::CmykConversion` trait, so +that callers can convert with their own colour management. `CmykSimulation::new` keeps +#2770's moxcms conversion as the default, and `CmykSimulation::with_conversion` takes a +caller's. Lab and ICCBased colours reach the conversion as CIE XYZ relative to the D50 PCS +white (`color::PCS_WHITE`); the moxcms conversion takes them on through #2770's ProPhoto hub. +`crates/pdf/tests/plates.rs` adapts #2770's plate tests to the raster path, with its +synthetic ICC profiles in `crates/pdf/tests/support/synthetic_icc.rs`. + The shared snapshot adds immutable per-document ordinary decode limits, threaded through initial xrefs, object streams, fallback and repair. Existing constructors retain their previous default. Known-size image budgets and streaming content budgets remain distinct. diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 3733e299..9a1b69ee 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "784753b972d9a24167c8085f4e387ac7cc85007f4ab4ca83ab0c0aafe067c073" + "sha256": "ed70bdb9618cf4d65032e977efe60b4d36f868fa4040db1012bfaf9ab4f5fb6e" }, { "path": "src/context.rs", @@ -362,7 +362,7 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "2f50fcd65da262f4ef66c9611adcdb993015c57181bc2a51a551611152a59f5f" + "sha256": "6b0c0080f1301bcdc24c03777b056d0501815c82cf958923595b29266349f50c" }, { "path": "src/interpret/path.rs", diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 55904609..46dac18b 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -785,11 +785,11 @@ impl Lab { ] } - /// PdfCraft patch (36): the colour `input` in the separation hub (see [`Hub`]), relative + /// PdfCraft patch (36): the colour `input` as XYZ relative to [`PCS_WHITE`], relative /// colorimetric: the space's white point becomes the PCS white (Bradford-adapted). - fn to_hub(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { + fn to_pcs(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { let xyz = self.xyz(self.components(input, manual_scale)); - HUB.from_xyz(&Self::adapt(&self.usable_white_point(), &HUB.white, &xyz)) + Self::adapt(&self.usable_white_point(), &PCS_WHITE, &xyz) } } @@ -1091,10 +1091,11 @@ impl ICCProfile { }))) } - /// PdfCraft patch (36): `input` (whole colours of this profile's components) in the - /// separation hub, three values per colour in `rgb`. L*a*b* components are scaled to the - /// profile's encoding unless `manual_scale` says they already are (an Indexed palette's). - fn to_hub(&self, input: &[f32], manual_scale: bool, rgb: &mut [f32]) -> Option<()> { + /// PdfCraft patch (36): `input` (whole colours of this profile's components) as XYZ + /// relative to [`PCS_WHITE`], three values per colour in `xyz`, through the separation hub + /// (see [`Hub`]). L*a*b* components are scaled to the profile's encoding unless + /// `manual_scale` says they already are (an Indexed palette's). + fn to_pcs(&self, input: &[f32], manual_scale: bool, xyz: &mut [f32]) -> Option<()> { let transform = self .0 .to_hub @@ -1110,7 +1111,7 @@ impl ICCProfile { .as_ref()?; let n = self.0.number_components; let samples = input.len() / n.max(1); - if samples > SIMULATION_CHUNK || rgb.len() != samples.checked_mul(3)? { + if samples > SIMULATION_CHUNK || xyz.len() != samples.checked_mul(3)? { return None; } // Values outside the encoding (or NaN) never reach the tables. @@ -1123,7 +1124,12 @@ impl ICCProfile { (false, _) => unit(*v), }; } - transform.transform(encoded, rgb).ok() + transform.transform(encoded, xyz).ok()?; + for colour in xyz.chunks_exact_mut(3) { + let pcs = HUB.to_pcs(&[colour[0], colour[1], colour[2]]); + colour.copy_from_slice(&pcs); + } + Some(()) } fn is_srgb(&self) -> bool { @@ -1433,31 +1439,62 @@ const SIMULATION_CHUNK: usize = 256; /// /// - DeviceRGB as sRGB, as pages are drawn; CalRGB and CalGray through their sRGB colours; /// - Lab and ICCBased colours (RGB, gray, L\*a\*b\*, and CMYK in another profile) through -/// the separation hub, a D50 RGB wide enough for every surface colour (see [`Hub`]); +/// CIE XYZ relative to the PCS white ([`PCS_WHITE`]); /// - DeviceCMYK, ICCBased CMYK in this same profile, and DeviceGray stay as they are: gray is a /// device colour, which a CMYK device prints with black ink alone (§10.4.2.2). /// /// Conversions are relative colorimetric, the initial rendering intent (§8.6.5.8; the -/// graphics state's `ri` isn't read). A profile without colorimetric tables is used through -/// its perceptual ones (tag 0, which every LUT-based ICC.1 profile has). Black point -/// compensation isn't applied. +/// graphics state's `ri` isn't read). A [`CmykConversion`] converts into the profile's CMYK: +/// [`CmykSimulation::new`] makes one with moxcms, and [`CmykSimulation::with_conversion`] +/// takes the caller's. #[derive(Clone)] pub struct CmykSimulation(Arc); struct SimulationRepr { - description: Option, - perceptual: bool, digest: u128, - from_srgb: Arc, - from_hub: Arc, - to_srgb: Option>, + conversion: Box, } +/// The conversion into a [`CmykSimulation`]'s CMYK, relative colorimetric. Each call converts +/// whole colours, at most 256 of them; CMYK components are `0.0..=1.0`, four per colour. A +/// method returns `None` when it can't convert, and the colour then separates by the device +/// formulas. +pub trait CmykConversion: Send + Sync { + /// sRGB colours, gamma-encoded, three components in `0.0..=1.0` per colour, to CMYK. + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()>; + + /// CIE XYZ colours relative to [`PCS_WHITE`] (the Y of white is 1), three components per + /// colour, to CMYK. The source's white has already been adapted to the PCS white. + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()>; + + /// CMYK bytes, four per pixel, to sRGB bytes, three per pixel, to show what the printing + /// condition prints. + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + let _ = (cmyk, rgb); + None + } + + /// The profile's description, to name the printing condition. + fn description(&self) -> Option<&str> { + None + } + + /// Whether the conversions use the profile's perceptual tables, because it has no + /// colorimetric ones. + fn perceptual(&self) -> bool { + false + } +} + +/// The D50 white of the ICC profile connection space (ICC.1 §7.2.16), the white that +/// [`CmykConversion::from_pcs_xyz`] takes its colours relative to. +pub const PCS_WHITE: [f32; 3] = [0.9642, 1.0, 0.8249]; + impl Debug for CmykSimulation { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("CmykSimulation") - .field("description", &self.0.description) - .field("perceptual", &self.0.perceptual) + .field("description", &self.description()) + .field("perceptual", &self.perceptual()) .finish_non_exhaustive() } } @@ -1481,12 +1518,72 @@ pub enum NoSimulation { } impl CmykSimulation { + /// The printing condition of the ICC profile `profile`, if it is a CMYK profile whose + /// PCS-to-device tables convert, converted to with moxcms. A profile without colorimetric + /// tables is used through its perceptual ones (tag 0, which every LUT-based ICC.1 profile + /// has). Black point compensation isn't applied. + pub fn new(profile: &[u8]) -> Result { + Ok(Self::with_conversion( + profile, + MoxcmsConversion::new(profile)?, + )) + } + + /// The printing condition of the CMYK ICC profile `profile`, converted to by `conversion`, + /// which the caller made from that profile. ICCBased CMYK colours in `profile` itself stay + /// as they are. + pub fn with_conversion(profile: &[u8], conversion: impl CmykConversion + 'static) -> Self { + Self(Arc::new(SimulationRepr { + digest: hash128(profile), + conversion: Box::new(conversion), + })) + } + + /// The profile's description; `None` when it has none. + pub fn description(&self) -> Option<&str> { + self.0.conversion.description() + } + + /// Whether conversions use the profile's perceptual tables, because it has no + /// colorimetric ones. + pub fn perceptual(&self) -> bool { + self.0.conversion.perceptual() + } + + /// A digest of the profile's bytes, for cache keys. + pub(crate) fn digest(&self) -> u128 { + self.0.digest + } + + fn conversion(&self) -> &dyn CmykConversion { + self.0.conversion.as_ref() + } + + /// CMYK in this printing condition to sRGB, relative colorimetric, to show what it prints. + /// As [`cmyk_to_srgb8`]; also `None` when the conversion can't show CMYK. + pub fn cmyk_to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { + return None; + } + self.conversion().to_srgb8(cmyk, rgb) + } +} + +/// PdfCraft patch (36): the conversion [`CmykSimulation::new`] makes with moxcms. XYZ colours +/// pass through the separation hub (see [`Hub`]). +struct MoxcmsConversion { + description: Option, + perceptual: bool, + from_srgb: Arc, + from_hub: Arc, + to_srgb: Option>, +} + +impl MoxcmsConversion { /// The longest description kept, in characters. const MAX_DESCRIPTION: usize = 160; - /// The printing condition of the ICC profile `profile`, if it is a CMYK profile whose - /// PCS-to-device tables convert. - pub fn new(profile: &[u8]) -> Result { + fn new(profile: &[u8]) -> Result { let parsed = ColorProfile::new_from_slice(profile).map_err(|_| NoSimulation::Unreadable)?; if parsed.color_space != DataColorSpace::Cmyk { return Err(NoSimulation::NotCmyk); @@ -1500,40 +1597,40 @@ impl CmykSimulation { let (from_srgb, _) = transform_f32(&SRGB, Layout::Rgb, &parsed, Layout::Rgba) .ok_or(NoSimulation::NoPcsToDevice)?; let to_srgb = transform_u8(&parsed, Layout::Rgba, &SRGB, Layout::Rgb); - Ok(Self(Arc::new(SimulationRepr { + Ok(Self { description: profile_description(&parsed, Self::MAX_DESCRIPTION), perceptual, - digest: hash128(profile), from_srgb, from_hub, to_srgb, - }))) + }) + } +} + +impl CmykConversion for MoxcmsConversion { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + self.from_srgb.transform(rgb, cmyk).ok() } - /// The profile's description (its `desc` tag) without control characters; `None` when it - /// has none. - pub fn description(&self) -> Option<&str> { - self.0.description.as_deref() - } - - /// Whether conversions use the profile's perceptual tables, because it has no - /// colorimetric ones. - pub fn perceptual(&self) -> bool { - self.0.perceptual - } - - /// A digest of the profile's bytes, for cache keys. - pub(crate) fn digest(&self) -> u128 { - self.0.digest - } - - /// CMYK in this printing condition to sRGB, relative colorimetric, to show what it prints. - /// As [`cmyk_to_srgb8`]; also `None` when the profile has no device-to-PCS tables. - pub fn cmyk_to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { - if cmyk.len() % 4 != 0 || (cmyk.len() / 4).checked_mul(3)? != rgb.len() { - return None; + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + let mut rgb = [0.0_f32; SIMULATION_CHUNK * 3]; + let rgb = rgb.get_mut(..xyz.len())?; + for (out, xyz) in rgb.chunks_exact_mut(3).zip(xyz.chunks_exact(3)) { + out.copy_from_slice(&HUB.from_pcs(&[xyz[0], xyz[1], xyz[2]])); } - self.0.to_srgb.as_ref()?.transform(cmyk, rgb).ok() + self.from_hub.transform(rgb, cmyk).ok() + } + + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + self.to_srgb.as_ref()?.transform(cmyk, rgb).ok() + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn perceptual(&self) -> bool { + self.perceptual } } @@ -1611,13 +1708,16 @@ fn transform_u8( static SRGB: LazyLock = LazyLock::new(ColorProfile::new_srgb); -/// PdfCraft patch (36): the separation hub, the RGB that Lab and ICCBased colours pass through -/// on their way to a simulated CMYK: ROMM RGB (ProPhoto) primaries, D50 white, gamma 1.8. Its +/// PdfCraft patch (36): the separation hub, the RGB that ICCBased colours leave their profile +/// in, and that [`MoxcmsConversion`] takes PCS XYZ to its CMYK through: ROMM RGB (ProPhoto) +/// primaries, D50 white, gamma 1.8. Its /// gamut holds practically every surface colour, so nothing printable clips on the way, and /// paper white and black land on exact grid points of the conversion's tables. (moxcms /// converts CMYK to CMYK and gray to CMYK only through an intermediate.) struct Hub { profile: ColorProfile, + /// Linear hub RGB to XYZ, row-major. + to_xyz: [f32; 9], /// XYZ to linear hub RGB, row-major. from_xyz: [f32; 9], /// The XYZ of hub white, `(1, 1, 1)`. @@ -1632,26 +1732,33 @@ impl Hub { let to_xyz = profile.rgb_to_xyz_matrix(); let from_xyz = to_xyz.inverse().v; let white = to_xyz.v.map(|row| (row[0] + row[1] + row[2]) as f32); + let flat = |m: [[f64; 3]; 3]| { + let mut out = [0.0_f32; 9]; + for (out, v) in out.iter_mut().zip(m.iter().flatten()) { + *out = *v as f32; + } + out + }; Self { profile, - from_xyz: [ - from_xyz[0][0] as f32, - from_xyz[0][1] as f32, - from_xyz[0][2] as f32, - from_xyz[1][0] as f32, - from_xyz[1][1] as f32, - from_xyz[1][2] as f32, - from_xyz[2][0] as f32, - from_xyz[2][1] as f32, - from_xyz[2][2] as f32, - ], + to_xyz: flat(to_xyz.v), + from_xyz: flat(from_xyz), white, } } - /// D50 XYZ (Y of white 1) as hub RGB, clipped to its gamut. - fn from_xyz(&self, xyz: &[f32; 3]) -> [f32; 3] { - CalRgb::matrix_product(&self.from_xyz, xyz).map(|v| unit(v).powf(1.0 / Self::GAMMA)) + /// XYZ relative to [`PCS_WHITE`] as hub RGB, clipped to its gamut. The PCS white is scaled + /// onto the hub's own D50 white, so that paper white lands on hub white exactly. + fn from_pcs(&self, xyz: &[f32; 3]) -> [f32; 3] { + let xyz = [0, 1, 2].map(|k| xyz[k] * self.white[k] / PCS_WHITE[k]); + CalRgb::matrix_product(&self.from_xyz, &xyz).map(|v| unit(v).powf(1.0 / Self::GAMMA)) + } + + /// Hub RGB as XYZ relative to [`PCS_WHITE`]; the inverse of [`Hub::from_pcs`] in gamut. + fn to_pcs(&self, rgb: &[f32; 3]) -> [f32; 3] { + let linear = rgb.map(|v| unit(v).powf(Self::GAMMA)); + let xyz = CalRgb::matrix_product(&self.to_xyz, &linear); + [0, 1, 2].map(|k| xyz[k] * PCS_WHITE[k] / self.white[k]) } } @@ -1700,6 +1807,8 @@ impl ColorSpace { if cmyk.len() != colours.checked_mul(4)? { return None; } + let conversion = simulation.conversion(); + // sRGB or PCS XYZ, by the space. let mut rgb = [0.0_f32; SIMULATION_CHUNK * 3]; let mut srgb = [0_u8; SIMULATION_CHUNK * 3]; for (input, cmyk) in input @@ -1709,12 +1818,12 @@ impl ColorSpace { let count = cmyk.len() / 4; let input = input.get(..count.checked_mul(n)?)?; let rgb = rgb.get_mut(..count * 3)?; - let to_cmyk = match self.0.as_ref() { + match self.0.as_ref() { ColorSpaceType::DeviceRgb => { for (out, v) in rgb.iter_mut().zip(input) { *out = unit(*v); } - &simulation.0.from_srgb + conversion.from_srgb(rgb, cmyk)?; } ColorSpaceType::CalRgb(_) | ColorSpaceType::CalGray(_) => { let srgb = srgb.get_mut(..count * 3)?; @@ -1722,21 +1831,20 @@ impl ColorSpace { for (out, v) in rgb.iter_mut().zip(srgb.iter()) { *out = u8_to_f32(*v); } - &simulation.0.from_srgb + conversion.from_srgb(rgb, cmyk)?; } ColorSpaceType::Lab(lab) => { for (out, colour) in rgb.chunks_exact_mut(3).zip(input.chunks_exact(3)) { - out.copy_from_slice(&lab.to_hub(colour, manual_scale)); + out.copy_from_slice(&lab.to_pcs(colour, manual_scale)); } - &simulation.0.from_hub + conversion.from_pcs_xyz(rgb, cmyk)?; } ColorSpaceType::ICCBased(icc) if self.simulated_by(simulation) => { - icc.to_hub(input, manual_scale, rgb)?; - &simulation.0.from_hub + icc.to_pcs(input, manual_scale, rgb)?; + conversion.from_pcs_xyz(rgb, cmyk)?; } _ => return None, - }; - to_cmyk.transform(rgb, cmyk).ok()?; + } } Some(()) } @@ -1848,7 +1956,7 @@ impl ColorSpace { /// PdfCraft patch (35): a process colour as DeviceCMYK: CMYK spaces (DeviceCMYK, ICC with /// four components) as they are, gray as black only (`k = 1 − gray`, §10.4.2.2), and RGB, - /// CIE-based and three-component ICC spaces through sRGB by [`rgb_to_cmyk`]. Patch 32: + /// CIE-based and three-component ICC spaces through sRGB by [`rgb_to_cmyk`]. Patch 36: /// the spaces `simulation` converts go through it instead, and only fall back to those /// formulas if the conversion fails. fn process_cmyk( @@ -1933,7 +2041,7 @@ impl ColorSpace { pub(crate) struct PlateSpace { inner: ColorSpace, ink: Ink, - /// Patch 32: the printing condition process colour converts to, if any. + /// Patch 36: the printing condition process colour converts to, if any. simulation: Option, } diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 99e21f7d..70bb05c7 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -122,7 +122,7 @@ pub struct InterpreterSettings { pub preview: bool, /// PdfCraft patch (35): draw one ink's separation instead of the composite page (see /// [`Plate`](crate::color::Plate)). Soft masks still draw in colour: a mask's luminosity - /// comes from its group's colours, not from any plate. Patch 32: the plate's `simulation` + /// comes from its group's colours, not from any plate. Patch 36: the plate's `simulation` /// converts process colour to an output intent's CMYK. pub plate: Option, /// PdfCraft patch (35): told about each spot ink a paint uses (colours, images, shadings From b01d19c7166628adb50849af23f09424b2e73a0f Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:07:22 +0000 Subject: [PATCH 04/11] vendor: port pdfcraft #2974 knockout groups into hayro Bring storytold/pdfcraft#2974 (source commit bae69819594a6385a027fec1f67e6b3c36c259b4) onto the vendored hayro-interpret and hayro. Knockout transparency 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 and a nested group's shape are cleared before their layer composites; the nested group's shape comes from an erase pass over its content. Masked images and stencils composite as before. Code comments label the change "Knockout patch (pdfcraft #2974)": the pull request numbers it hayro-interpret patch 39 and hayro patch 19, which pdfcraft may renumber. The erase pass resolves resources with Resources::from_scope, as this tree's form drawing does. crates/pdf/tests/knockout.rs adapts the pull request's test to the raster path (without its preview case: craft-pdf has no preview mode). A plate-mode test checks that a knockout group of two 50% squares separates with only the top square in the overlap. Options documents that color_replacement also maps plate films. Ledger, NOTICE and the source manifests are updated. Co-authored-by: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> --- NOTICE | 4 + crates/pdf/src/raster.rs | 2 + crates/pdf/tests/knockout.rs | 162 +++++++++++++++++++ crates/pdf/tests/plates.rs | 29 ++++ vendor/README.craft-reader.md | 11 +- vendor/hayro-interpret/SOURCE.json | 4 +- vendor/hayro-interpret/src/device.rs | 34 ++++ vendor/hayro-interpret/src/x_object.rs | 48 +++++- vendor/hayro/SOURCE.json | 2 +- vendor/hayro/src/renderer.rs | 214 +++++++++++++++++++++---- 10 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 crates/pdf/tests/knockout.rs diff --git a/NOTICE b/NOTICE index 29578b98..862cec11 100644 --- a/NOTICE +++ b/NOTICE @@ -105,6 +105,10 @@ The hayro-interpret plate mode (separations, overprint and output-intent simulat from PdfCraft pull request #2770 by Guillem Hermida, commit 6fcf2a4aca29e943e28cb69017ff8f87d00656c6 (https://github.com/storytold/pdfcraft/pull/2770), MIT OR Apache-2.0; crates/pdf/tests/plates.rs and tests/support/synthetic_icc.rs adapt its tests. +Knockout transparency groups in hayro-interpret and hayro are ported from PdfCraft pull request +#2974 by mtlcl410, commit bae69819594a6385a027fec1f67e6b3c36c259b4 +(https://github.com/storytold/pdfcraft/pull/2974), MIT OR Apache-2.0; +crates/pdf/tests/knockout.rs adapts its test. The reader retains the existing Foxit/PDFium standard-14 font assets (BSD-3-Clause), Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Clause). Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index bf1fc931..9b170d1f 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -26,6 +26,8 @@ pub struct Options { /// Straight-alpha sRGB bytes; output pixels are always premultiplied. pub background: [u8; 4], /// Generic solid-colour mapping; reading presets and print bypass policy belong to callers. + /// It also maps a plate's gray film (`InterpreterSettings::plate`), so a plate render that + /// must keep its tints leaves it `None`. Knockout clears are never mapped. pub color_replacement: Option, } diff --git a/crates/pdf/tests/knockout.rs b/crates/pdf/tests/knockout.rs new file mode 100644 index 00000000..e1dbf437 --- /dev/null +++ b/crates/pdf/tests/knockout.rs @@ -0,0 +1,162 @@ +//! Knockout transparency groups through the raster path. Adapted from PdfCraft #2974 by mtlcl410 +//! (`raster::tests::knockout_groups_replace_what_their_elements_cover` in pdfcraft-render at +//! bae69819594a6385a027fec1f67e6b3c36c259b4, MIT OR Apache-2.0). +#![cfg(feature = "raster")] + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::{ + open_slice, + raster::{self, Options, RenderCache}, +}; + +/// A 300 × 200 pt page that draws form `Fm1` (a transparency group with `group` entries and +/// content `form`), then a green square at 50 % opacity. +fn knockout_pdf(group: &str, form: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length 39 >> stream +/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream +{form} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream +/A gs 0 0 1 rg 100 50 150 120 re f +endstream endobj +7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream +0000> +endstream endobj +8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream +/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f +endstream endobj +9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream +0000FF0000FF> +endstream endobj +11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream +00FF> +endstream endobj +12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream +/H gs q 160 0 0 100 60 50 cm /Img Do Q +endstream endobj +13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream +/H gs /In3 Do +endstream endobj +14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream +0 0 1 rg 100 50 150 120 re f +endstream endobj +15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj +16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream +/Pattern cs /P scn 100 50 150 120 re f +endstream endobj +17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + form.len() + ) + .into_bytes() +} + +/// The page rendered over white, as RGBA8, 300 × 200 pixels. +fn render(group: &str, form: &str) -> Vec { + let pdf = knockout_pdf(group, form); + let doc = open_slice(&pdf, None).unwrap(); + let options = Options { background: [255; 4], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &InterpreterSettings::default(), &options).unwrap(); + assert_eq!((raster.width, raster.height), (300, 200)); + raster.bytes().to_vec() +} + +/// The RGB at PDF point (x, y); pixel rows run top down. +fn px(page: &[u8], x: usize, y: usize) -> [u8; 3] { + let p = &page[((200 - y) * 300 + x) * 4..][..3]; + [p[0], p[1], p[2]] +} + +fn check(page: &[u8], x: usize, y: usize, want: [u8; 3], what: &str) { + let got = px(page, x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}"); +} + +/// In a knockout transparency group (`/K true`) each element composites with the group's initial +/// backdrop, not with the elements drawn before it in the group, over the element's shape. A blue +/// square at 50 % opacity over a red one at 50 % shows only the blue where they overlap; without +/// `/K` the two blend. The same holds for an image (its shape is its rectangle; a soft-masked +/// image's shape is its mask, so its transparent part leaves what is under it) and for a nested +/// group (its shape is the union of its elements' shapes, while its own elements still blend with +/// each other). +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn knockout_groups_replace_what_their_elements_cover() { + let red = "/A gs 1 0 0 rg 20 20 150 150 re f "; + let ko = "/I true /K true"; + + let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&squares, 130, 100, [128, 128, 255], "knocked out"); + check(&squares, 50, 40, [255, 128, 128], "red alone"); + check(&squares, 210, 100, [128, 128, 255], "blue alone"); + check(&squares, 280, 190, [255, 255, 255], "paper"); + // Page content after the group composites normally again: green at 50 % over the red. + check(&squares, 30, 30, [128, 191, 64], "after the group"); + let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&plain, 130, 100, [128, 64, 191], "blended without /K"); + + // An image at 50 % replaces the red under its rectangle. + let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q")); + check(&image, 100, 100, [128, 128, 255], "image knocks out"); + check(&image, 40, 40, [255, 128, 128], "red beside the image"); + // A soft-masked image: its transparent left half leaves the red, its opaque half replaces it. + let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q"); + check(&masked, 100, 100, [255, 0, 0], "under the transparent half"); + check(&masked, 155, 100, [0, 0, 255], "under the opaque half"); + + // A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape. + let nested = render(ko, &format!("{red}/In Do")); + check(&nested, 130, 100, [191, 191, 255], "nested group knocks out"); + check(&nested, 50, 40, [255, 128, 128], "red beside the nested group"); + // A nested group at full opacity: it knocks out the red as a whole, and its own two squares + // at 50 % still blend with each other where they overlap. + let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do"); + check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red"); + check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend"); + check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group"); + + // An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red. + let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do"); + check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group"); + // A group inside a nested group knocks out the red as well. + let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do"); + check(&deep, 130, 100, [128, 128, 255], "group in a nested group"); + check(&deep, 40, 40, [255, 0, 0], "red beside it"); + + // A stencil mask must not erase the red beside it. Where the stencil paints (every sample 0) + // its blue is drawn; a stencil composites rather than knocking the red out, and its exact + // colour also depends on its fill opacity (pdfcraft #2975), so only "blue was drawn here" is + // pinned. + let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q")); + check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil"); + let under = px(&stencil, 130, 100); + assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}"); + + // A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path + // inside the box: the erase pass clears only that, and the red outside the box but inside + // the path stays. + let shaded = render(ko, &format!("{red}/Shg Do")); + check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box"); + check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box"); + + // An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it + // doesn't clear what is under its rectangle either. + let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q")); + check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn"); + // A tiny image still knocks out in a final render. + let tiny = render(ko, &format!("{red}q 3 0 0 3 100 100 cm /Img Do Q")); + check(&tiny, 101, 101, [128, 128, 255], "a tiny image"); +} diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs index 8588e3cf..90bee344 100644 --- a/crates/pdf/tests/plates.rs +++ b/crates/pdf/tests/plates.rs @@ -446,3 +446,32 @@ fn a_callers_cmyk_conversion_separates_process_colour() { assert!(c.iter().zip(PCS_WHITE).all(|(v, w)| (v - w).abs() < 1e-3), "a Lab white arrives as the PCS white: {c:?}"); } } + +/// A knockout group (`/K true`, pdfcraft #2974) of two process squares at 50 % opacity separates +/// with only the top square where they overlap: its plates replace the bottom square's there. +/// Without `/K` the two blend on every plate. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_knockout_group_separates_with_only_the_top_object_in_the_overlap() { + let group = |entries: &str| { + let form = "/A gs 1 0 0 0 k 0 0 25 25 re f 0 1 0 0 k 15 15 25 25 re f"; + page_pdf( + "/Fm Do", + "/XObject << /Fm 5 0 R >>", + &[stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency {entries} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >>" + ), + form.as_bytes(), + )], + ) + }; + // Device rows run top down: the overlap is at (20, 20), cyan alone at (5, 35), magenta alone + // at (35, 5). + let knockout = plates(&group("/K true"), &inks(&[]), true, None); + assert_eq!(knockout.at(20, 20), [0, 50, 0, 0], "only the magenta square in the overlap"); + assert_eq!(knockout.at(5, 35), [50, 0, 0, 0]); + assert_eq!(knockout.at(35, 5), [0, 50, 0, 0]); + let blended = plates(&group(""), &inks(&[]), true, None); + assert_eq!(blended.at(20, 20), [25, 50, 0, 0], "both squares without /K"); +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index ca41a2dd..91d6bf4b 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -33,11 +33,20 @@ PdfCraft's vendored `vello_common` patch (clip-intersection performance) is not reader snapshot. Changes ported from PdfCraft pull requests that were open when they were ported, under the -same MIT OR Apache-2.0 licence. Code comments keep the pull request's patch numbers. +same MIT OR Apache-2.0 licence. 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 colour 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 colour management. `CmykSimulation::new` keeps diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 9a1b69ee..99267ef4 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -200,7 +200,7 @@ "path": "src/device.rs", "source_sha256": "6f0c1203d1e60724cdd11e9a230249247a70f578c4a7ccb9bf39a23411b32db6", "source_git_blob": "36c64a23fd23f6c4abad941077cf93296a67841f", - "sha256": "6f0c1203d1e60724cdd11e9a230249247a70f578c4a7ccb9bf39a23411b32db6" + "sha256": "abe1a0a835deb409b4d466e1d0f041a0e53ef8981be0929070afc77258cd8434" }, { "path": "src/encode.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "20e9d2f4fcafd20e11d6255f2800c8732ea84e74e5061287c8d5fbcfb4ffe013" + "sha256": "a1828456a715f0d2a6a76ab10656a018d4b94c4012050b739bce09728ac27225" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 36c64a23..57d11be2 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -73,6 +73,40 @@ pub trait Device<'a> { fn begin_annotations(&mut self) {} /// PdfCraft patch (29): the page's annotations are done; what follows is page content. fn end_annotations(&mut self) {} + /// Knockout patch (pdfcraft #2974): the transparency group just pushed is a knockout group + /// (`/K true`): each of its elements composites with the group's initial backdrop, not with the + /// elements drawn before it in the group (ISO 32000-2, knockout groups), until the matching + /// `pop_transparency_group`. + fn set_knockout(&mut self) {} + /// Knockout patch (pdfcraft #2974): an element whose shape is `shape` (device space) is about + /// to be drawn into a transparency group of its own. In a knockout group (`set_knockout`) the + /// element replaces what the group drew under its shape, so a device clears that first; the + /// element's layer then composites over the cleared area as usual. + fn knockout_shape(&mut self, _shape: &BezPath) {} + /// Knockout patch (pdfcraft #2974): whether `knockout_shape` would clear anything now, that is + /// whether the innermost transparency group is a knockout group. Lets the interpreter skip work + /// it only needs for the clear. + fn in_knockout_group(&mut self) -> bool { + false + } + /// Knockout patch (pdfcraft #2974): `image`, an unmasked raster image, is about to be drawn + /// with `transform` in a knockout group. A device that will draw it (it isn't skipped and it + /// decodes) clears what the group drew under its placement rectangle first, so the image + /// replaces it. Decided by the device, at the size it draws the image, so the draw reuses that + /// decode. + fn knockout_image(&mut self, _image: &crate::RasterImage<'_>, _transform: Affine) {} + /// Knockout patch (pdfcraft #2974): whether a transparency group drawn now is an element of a + /// knockout group that the device wants cleared under the group's shape first. If so the + /// interpreter draws the group's content once between `begin_knockout_erase` and + /// `end_knockout_erase`, in which every paint clears what is under its shape instead of + /// painting, and then draws the group as usual, in a layer of its own. + fn knockout_erase_wanted(&mut self) -> bool { + false + } + /// Knockout patch (pdfcraft #2974): see `knockout_erase_wanted`. + fn begin_knockout_erase(&mut self) {} + /// Knockout patch (pdfcraft #2974): see `knockout_erase_wanted`. + fn end_knockout_erase(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 6a846148..83a0aadd 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -159,7 +159,38 @@ pub(crate) fn draw_form_xobject<'a, 'b>( && state.soft_mask.is_none() && state.blend_mode == BlendMode::Normal }; - let own_layer = x_object.is_transparency_group && !neutral; + // 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( @@ -170,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()); @@ -282,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), diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index 260e2c55..737e78b5 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -68,7 +68,7 @@ "path": "src/renderer.rs", "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", - "sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b" + "sha256": "81ac25ccc066b4e84e7dedf3abee813948e443c87a32f9f28cb067c3a383904d" } ] } diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 6aa53716..fafe4d79 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -435,6 +435,14 @@ pub(crate) struct Renderer { /// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their /// colours — reading colours replace document colours, never annotation colours. in_annotations: bool, + /// Knockout patch (pdfcraft #2974): one entry per transparency group pushed, whether it is a + /// knockout group (`Device::set_knockout`). + knockout: Vec, + /// Knockout patch (pdfcraft #2974): inside a knockout erase pass + /// (`Device::begin_knockout_erase`), and how many transparency groups were skipped in it + /// (nothing is layered while erasing). + erasing: bool, + erase_groups: usize, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -458,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, @@ -470,6 +484,9 @@ impl Renderer { 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(), @@ -571,6 +588,9 @@ impl Renderer { // 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(), @@ -735,6 +755,40 @@ impl Renderer { } } + /// The size an image of `width` x `height` samples is decoded at to be drawn with + /// `transform`, or `None` if `draw_image` skips it: off the canvas (PdfCraft patch: see + /// `may_paint_rect`; checked before decoding, so a tile no longer decodes every image on its + /// page), or, in a preview, covering only a few device pixels (PdfCraft patch; final renders + /// always draw them). The tiny-image test measures axis lengths: the size returned maps a + /// point, so it includes the translation and only suits the decode hint. + fn image_draw_size(&self, width: u32, height: u32, transform: Affine) -> Option<(u32, u32)> { + if !may_paint_rect( + Rect::new(0.0, 0.0, f64::from(width), f64::from(height)), + transform, + self.ctx.width(), + self.ctx.height(), + ) { + return None; + } + let target_width = (transform * Point::new(width as f64, 0.0)) + .to_vec2() + .length() + .ceil() as u32; + let target_height = (transform * Point::new(0.0, height as f64)) + .to_vec2() + .length() + .ceil() as u32; + if self.preview { + let c = transform.as_coeffs(); + let tiny_w = (f64::from(width) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32; + let tiny_h = (f64::from(height) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32; + if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA { + return None; + } + } + Some((target_width, target_height)) + } + fn draw_image(&mut self, image_data: ImageData, alpha_data: Option) { let cur_transform = *self.ctx.transform(); let (x_scale, y_scale) = { @@ -1040,6 +1094,20 @@ impl Renderer { #[must_use] fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option { + // Knockout patch (pdfcraft #2974): in a knockout erase pass every paint clears what is + // under its shape (a transparent fill with `Compose::Copy`, weighted by coverage). + if self.erasing { + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + // A shading's `/BBox` clips what it paints, so it clips what it clears too. + return match paint { + Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(s) => s.shading.clip_path.clone(), + _ => None, + }, + _ => None, + }; + } let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; @@ -1163,6 +1231,9 @@ impl Renderer { ), 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(), @@ -1217,6 +1288,9 @@ impl Renderer { // 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(), @@ -1410,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( @@ -1533,6 +1586,9 @@ impl<'a> Device<'a> for Renderer { // 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(), @@ -1605,6 +1661,11 @@ impl<'a> Device<'a> for Renderer { mask: Option>, blend_mode: BlendMode, ) { + // Knockout patch (pdfcraft #2974): nothing is layered in a knockout erase pass. + if self.erasing { + self.erase_groups += 1; + return; + } let settings = *self.ctx.render_settings(); let force_images_may_have_transparency = mask.is_some() || blend_mode != BlendMode::Normal @@ -1631,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) { @@ -1638,10 +1700,86 @@ impl<'a> Device<'a> for Renderer { } fn pop_transparency_group(&mut self) { + if self.erasing && self.erase_groups > 0 { + self.erase_groups -= 1; + return; + } self.image_transparency_stack.pop(); + self.knockout.pop(); self.ctx.pop_layer(); } + /// Knockout patch (pdfcraft #2974): the group just pushed is a knockout group. Its elements are + /// drawn with `Compose::Copy`, which (weighted by coverage) replaces what earlier elements drew + /// where the element's shape is, so each element composites with the group's initial backdrop + /// (transparent: an isolated group, and the non-isolated approximation every group layer here + /// already makes) instead of with the elements before it. Against a transparent backdrop the + /// element's own blend mode has nothing to blend with, so the mix is Normal. This covers paths, + /// strokes, glyph outlines and shadings. Images and nested groups are drawn into a layer of + /// their own, which can't be copied (it covers the whole canvas, transparent where nothing was + /// drawn): what is under their shape is cleared first (`knockout_shape`, the knockout erase + /// pass) and the layer composites over it. + fn set_knockout(&mut self) { + if let Some(top) = self.knockout.last_mut() { + *top = true; + self.ctx.set_blend_mode(knockout_blend_mode()); + } + } + + /// Knockout patch (pdfcraft #2974): in a knockout group, clear what the group drew under + /// `shape` (a transparent fill with `Compose::Copy`, weighted by coverage) before an element + /// drawn into a layer of its own composites there, so the element replaces it. + fn knockout_erase_wanted(&mut self) -> bool { + self.in_knockout() && !self.erasing + } + + fn begin_knockout_erase(&mut self) { + self.erasing = true; + self.erase_groups = 0; + } + + fn end_knockout_erase(&mut self) { + self.erasing = false; + self.erase_groups = 0; + self.ctx.set_blend_mode(knockout_blend_mode()); + } + + fn in_knockout_group(&mut self) -> bool { + self.in_knockout() + } + + fn knockout_image(&mut self, image: &hayro_interpret::RasterImage<'_>, transform: Affine) { + if !self.in_knockout() { + return; + } + // Only an image `draw_image` will draw, decoded at the size it draws it (the same + // decode, so a cached decode serves both). + let Some(size) = self.image_draw_size(image.width(), image.height(), transform) else { + return; + }; + let mut decodes = false; + image.with_rgba(|_, _| decodes = true, Some(size)); + if decodes { + let rect = Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())); + self.knockout_shape(&(transform * rect.to_path(0.1))); + } + } + + fn knockout_shape(&mut self, shape: &BezPath) { + if !self.in_knockout() { + return; + } + let old_transform = *self.ctx.transform(); + let old_rule = *self.ctx.fill_rule(); + self.ctx.set_transform(Affine::IDENTITY); + self.ctx.set_fill_rule(Fill::NonZero); + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + self.ctx.fill_path(shape); + self.ctx.set_fill_rule(old_rule); + self.ctx.set_transform(old_transform); + } + fn set_soft_mask(&mut self, mask: Option>) { let settings = *self.ctx.render_settings(); self.cur_mask = mask.map(|m| { @@ -1745,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); } } @@ -1829,6 +1974,9 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei // 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(), @@ -1923,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, From c025c0dfffef74ca8127d519afe5381032622d51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:15:36 +0000 Subject: [PATCH 05/11] separations: add craft-separations with the ink model and plates The ink model (process and spot inks, InkSet with PDF name lookup, InkColor, Overprint) and plates(pdf, page, dpi, opts): ink discovery and one gray film per ink through craft-pdf's raster plate mode, with process colour converted through craft-cms (output intent, else the caller's profile or FOGRA39). Plate rendering, ink discovery, caps and output-intent selection port pdfcraft #2770's separations.rs. Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> --- Cargo.lock | 9 + Cargo.toml | 1 + crates/separations/Cargo.toml | 16 ++ crates/separations/README.md | 79 +++++++ crates/separations/src/cms.rs | 143 +++++++++++++ crates/separations/src/ink.rs | 261 +++++++++++++++++++++++ crates/separations/src/lib.rs | 12 ++ crates/separations/src/plates.rs | 320 +++++++++++++++++++++++++++++ crates/separations/tests/plates.rs | 147 +++++++++++++ 9 files changed, 988 insertions(+) create mode 100644 crates/separations/Cargo.toml create mode 100644 crates/separations/README.md create mode 100644 crates/separations/src/cms.rs create mode 100644 crates/separations/src/ink.rs create mode 100644 crates/separations/src/lib.rs create mode 100644 crates/separations/src/plates.rs create mode 100644 crates/separations/tests/plates.rs diff --git a/Cargo.lock b/Cargo.lock index 6471411f..ba814a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,6 +657,15 @@ dependencies = [ "objc2-app-kit 0.3.2", ] +[[package]] +name = "craft-separations" +version = "0.1.0" +dependencies = [ + "craft-cms", + "craft-pdf", + "thiserror 2.0.21", +] + [[package]] name = "craft-settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 868e568d..2ba6ad5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ craft-docpath = { path = "crates/docpath" } craft-i18n = { path = "crates/i18n" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } +craft-separations = { path = "crates/separations" } craft-settings = { path = "crates/settings" } craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } diff --git a/crates/separations/Cargo.toml b/crates/separations/Cargo.toml new file mode 100644 index 00000000..4cdec570 --- /dev/null +++ b/crates/separations/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-separations" +description = "Ink model and per-ink plates rendered from a PDF, for the Craft apps' print output" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-pdf = { workspace = true, features = ["raster"] } +craft-cms = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/separations/README.md b/crates/separations/README.md new file mode 100644 index 00000000..9eef7eb8 --- /dev/null +++ b/crates/separations/README.md @@ -0,0 +1,79 @@ +# craft-separations + +Print separations for the Craft apps: + +- `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; `InkColor` is a colour 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. +- `cms`: process colour conversion into a CMYK printing condition through craft-cms. + +## Plates from an exported PDF + +An app exports its PDF as usual, then separates a page of it: + +```rust +use craft_separations::{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 colour + +RGB, CIE-based and ICCBased colours 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. + +## Credits + +- Plate mode, ink discovery and the output-intent handling come from + [storytold/pdfcraft#2770](https://github.com/storytold/pdfcraft/pull/2770) by Guillem Hermida + (guillehr2), with craft-cms in place of moxcms. +- Knockout groups in the renderer come from + [storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410. + +Both MIT OR Apache-2.0, as this crate. diff --git a/crates/separations/src/cms.rs b/crates/separations/src/cms.rs new file mode 100644 index 00000000..bf0d8d85 --- /dev/null +++ b/crates/separations/src/cms.rs @@ -0,0 +1,143 @@ +//! Process colour 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 colour into a CMYK printing condition with craft-cms: relative +/// colorimetric, without black point compensation, as the plate renderer expects (see +/// [`CmykConversion`]). CMYK shows as sRGB relative colorimetric with black point +/// compensation. Transforms are built on first use and shared through craft-cms's cache. +pub struct CmsConversion { + profile: Profile, + perceptual: bool, + from_srgb: OnceLock>>, + from_lab: OnceLock>>, + to_srgb: OnceLock>>, +} + +/// Why a profile can't be a printing condition. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ProfileError { + #[error("the profile could not be read: {0}")] + Unreadable(String), + #[error("the profile is not CMYK")] + NotCmyk, + #[error("the profile has no PCS-to-device (BToA) tables")] + NoPcsToDevice, +} + +impl CmsConversion { + /// The printing condition of ICC profile bytes `icc`. + pub fn new(icc: &[u8]) -> Result { + Self::from_profile(Profile::parse(icc).map_err(|e| ProfileError::Unreadable(format!("{e:?}")))?) + } + + /// The printing condition of a parsed CMYK profile with PCS-to-device tables. + pub fn from_profile(profile: Profile) -> Result { + if profile.color_space != ColorSpace::Cmyk { + return Err(ProfileError::NotCmyk); + } + if profile.pcs_to_device(Intent::RelativeColorimetric).is_err() { + return Err(ProfileError::NoPcsToDevice); + } + // Without a colorimetric BToA1 table craft-cms uses BToA0, the perceptual one. + let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some(); + Ok(Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() }) + } + + /// craft-cms's Coated FOGRA39 profile. + pub fn fogra39() -> Self { + let profile = Builtin::CoatedFogra39.profile().clone(); + let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some(); + Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() } + } + + /// A plate-mode printing condition converting through this, for the profile bytes `icc` + /// this was made from. ICCBased CMYK colours embedding those same bytes separate as they + /// are. + pub fn simulation(self, icc: &[u8]) -> CmykSimulation { + CmykSimulation::with_conversion(icc, self) + } + + fn transform(cell: &OnceLock>>, src: &Profile, dst: &Profile, bpc: bool) -> Option> { + cell.get_or_init(|| { + // The fast path interpolates the same tables that 8-bit buffers use: plates convert + // images sample by sample through here. + let opts = TransformOptions { intent: Intent::RelativeColorimetric, bpc, precise_float: false }; + cached(src, dst, opts).ok() + }) + .clone() + } +} + +fn whole(input: &[f32], channels: usize, cmyk: &[f32]) -> bool { + input.len().is_multiple_of(channels) && cmyk.len().is_multiple_of(4) && input.len() / channels == cmyk.len() / 4 +} + +fn finite01(cmyk: &mut [f32]) { + for v in cmyk { + *v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + } +} + +impl CmykConversion for CmsConversion { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + if !whole(rgb, 3, cmyk) { + return None; + } + let t = Self::transform(&self.from_srgb, builtin::srgb(), &self.profile, false)?; + t.convert_f32(rgb, 3, cmyk, 4, false).ok()?; + finite01(cmyk); + Some(()) + } + + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + if !whole(xyz, 3, cmyk) { + return None; + } + // Into the Lab D50 identity profile's device values (ICC v4 Lab encoding). + let lab: Vec = xyz + .as_chunks::<3>() + .0 + .iter() + .flat_map(|c| { + let [l, a, b] = math::xyz_to_lab_f32(*c); + [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0] + }) + .collect(); + let t = Self::transform(&self.from_lab, Builtin::LabD50.profile(), &self.profile, false)?; + t.convert_f32(&lab, 3, cmyk, 4, false).ok()?; + finite01(cmyk); + Some(()) + } + + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if !cmyk.len().is_multiple_of(4) || cmyk.len() / 4 * 3 != rgb.len() { + return None; + } + let t = Self::transform(&self.to_srgb, &self.profile, builtin::srgb(), true)?; + t.convert_u8(cmyk, 4, rgb, 3, false).ok() + } + + fn description(&self) -> Option<&str> { + let d = self.profile.description.trim(); + (!d.is_empty()).then_some(d) + } + + fn perceptual(&self) -> bool { + self.perceptual + } +} + +/// CIE L\*a\*b\* (D50) of an sRGB colour, relative colorimetric; `None` if craft-cms can't +/// convert. +pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { + static T: OnceLock>> = OnceLock::new(); + let t = CmsConversion::transform(&T, builtin::srgb(), Builtin::LabD50.profile(), false)?; + let mut lab = [0.0_f32; 3]; + let rgb = rgb.map(|c| f32::from(c) / 255.0); + t.convert_f32(&rgb, 3, &mut lab, 3, false).ok()?; + Some([lab[0] * 100.0, lab[1] * 255.0 - 128.0, lab[2] * 255.0 - 128.0]) +} diff --git a/crates/separations/src/ink.rs b/crates/separations/src/ink.rs new file mode 100644 index 00000000..77da40b7 --- /dev/null +++ b/crates/separations/src/ink.rs @@ -0,0 +1,261 @@ +//! The ink model: which inks a job prints with, and colours as tints of those inks. + +use std::borrow::Cow; + +/// 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` colour space gives this ink. + pub fn name(self) -> &'static str { + match self { + Self::Cyan => "Cyan", + Self::Magenta => "Magenta", + Self::Yellow => "Yellow", + Self::Black => "Black", + } + } + + /// The process ink a colorant name denotes (ISO 32000-2 §8.6.6.4). + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|p| p.name() == name) + } + + /// The ink's position in a CMYK colour. + pub fn index(self) -> usize { + self as usize + } +} + +/// How a spot ink looks, for previews and for printing it as process colour. +#[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` colour space would name it, without PDF name + /// escapes (see [`normalize_name`]). + pub name: String, + /// The ink at 100%. + pub alternate: Alternate, + /// How opaque the ink is (`0.0..=1.0`, as InDesign's Ink Manager states it), when known. + pub solidity: Option, +} + +/// A process or spot ink. +#[derive(Clone, Debug, PartialEq)] +pub enum Ink { + Process(Process), + Spot(Spot), +} + +impl Ink { + /// The colorant name: `Cyan` … `Black`, or the spot ink's name. + pub fn name(&self) -> &str { + match self { + Self::Process(p) => p.name(), + Self::Spot(s) => &s.name, + } + } + + pub fn is_spot(&self) -> bool { + matches!(self, Self::Spot(_)) + } +} + +/// An ink's index in its [`InkSet`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct InkId(u16); + +impl InkId { + /// The ink's position in [`InkSet::iter`] order, which is also its plane in + /// [`Plates::planes`](crate::Plates::planes). + pub fn index(self) -> usize { + usize::from(self.0) + } + + /// The id of process ink `p`, the same in every [`InkSet`]. + pub fn process(p: Process) -> Self { + Self(p as u16) + } +} + +/// The inks of one document or job: the four process inks, always first and in plate order, +/// then spot inks in the order added. Ids stay valid as inks are added. +#[derive(Clone, Debug, PartialEq)] +pub struct InkSet { + inks: Vec, +} + +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() } + } + + /// 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 normalised. `None` for the + /// names `All` and `None`, which are not inks (§8.6.6.4), and when the set is full + /// (65 536 inks). + pub fn add(&mut self, ink: Ink) -> Option { + let spot = match ink { + Ink::Process(p) => return Some(InkId::process(p)), + Ink::Spot(spot) => spot, + }; + let name = normalize_name(&spot.name).into_owned(); + if name == "All" || name == "None" { + return None; + } + if let Some(id) = self.lookup(&name) { + return Some(id); + } + let id = InkId(u16::try_from(self.inks.len()).ok()?); + self.inks.push(Ink::Spot(Spot { name, ..spot })); + Some(id) + } + + /// The ink named `name`, compared after [`normalize_name`]: `"/Gold#20Foil"` finds + /// `Gold Foil`. + pub fn lookup(&self, name: &str) -> Option { + let name = normalize_name(name); + if let Some(p) = Process::from_name(&name) { + return Some(InkId::process(p)); + } + let at = self.inks.iter().skip(4).position(|ink| ink.name() == name)?; + u16::try_from(at + 4).ok().map(InkId) + } + + pub fn get(&self, id: InkId) -> Option<&Ink> { + self.inks.get(id.index()) + } + + /// Every ink with its id: the process inks, then the spot inks. + pub fn iter(&self) -> impl Iterator { + // At most 65 536 inks (`add`), so every index fits. + self.inks.iter().enumerate().map(|(i, ink)| (InkId(i as u16), ink)) + } + + /// The spot inks with their ids. + pub fn spots(&self) -> impl Iterator { + self.iter().filter_map(|(id, ink)| match ink { + Ink::Spot(s) => Some((id, s)), + Ink::Process(_) => None, + }) + } + + /// The number of inks, process inks included. + pub fn len(&self) -> usize { + self.inks.len() + } + + /// Always `false`: a set holds the process inks. + pub fn is_empty(&self) -> bool { + self.inks.is_empty() + } +} + +/// A colour 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` colour, several a `DeviceN` one. + Tints(Vec<(InkId, f32)>), + /// The same tint on every plate: the registration colour (the `All` colorant). + All(f32), + /// No ink on any plate (the `None` colorant). + None, +} + +/// 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); + } + + #[test] + fn malformed_escapes_stay_as_written() { + assert_eq!(normalize_name("A#2"), "A#2"); + assert_eq!(normalize_name("A#zz#41"), "A#zzA"); + assert_eq!(normalize_name("#E2#82#AC"), "€"); + } +} diff --git a/crates/separations/src/lib.rs b/crates/separations/src/lib.rs new file mode 100644 index 00000000..73a131c7 --- /dev/null +++ b/crates/separations/src/lib.rs @@ -0,0 +1,12 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Print separations for the Craft apps: an ink model shared by the writers, plates rendered +//! from a PDF, and process colour conversion through craft-cms. See the README. + +pub mod cms; +pub mod ink; +pub mod plates; + +pub use cms::{CmsConversion, ProfileError}; +pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name}; +pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/separations/src/plates.rs b/crates/separations/src/plates.rs new file mode 100644 index 00000000..33b72fac --- /dev/null +++ b/crates/separations/src/plates.rs @@ -0,0 +1,320 @@ +//! A PDF page separated into plates, one gray film per ink. +//! +//! A plate is the page rendered with hayro-interpret's plate mode +//! (`InterpreterSettings::plate`): every paint draws its tint of one ink as gray, and a paint +//! that overprints without naming the ink leaves the film as it is (ISO 32000-2 §8.6.7). +//! Clipping, anti-aliasing, soft masks, transparency groups and blending stay the renderer's +//! own. `Separation` and `DeviceN` colorants paint their own plates, DeviceCMYK its four +//! components, DeviceGray black only, and other process colour 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 colour 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 undercolour 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 { + /// Honour `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 colour that isn't CMYK already became CMYK. +#[derive(Clone, Debug, PartialEq)] +pub enum Conversion { + /// Through the CMYK profile of the page's or document's output intent (ISO 32000-2 + /// §14.11.5). + OutputIntent { + /// The profile's description, else the intent's output condition. + profile: String, + /// The intent's `/OutputConditionIdentifier`, such as `FOGRA39`. + condition: Option, + }, + /// Through [`Fallback::Fogra39`] or [`Fallback::Profile`]; the profile's description. + Fallback { profile: String }, + /// By [`Fallback::DeviceFormulas`]. + DeviceFormulas, +} + +/// A page separated into plates. +#[derive(Clone, Debug)] +pub struct Plates { + /// The process inks, then the spot inks the page paints, in the order first painted. Spots + /// carry a Lab alternate measured from their sRGB appearance at 100%. + pub inks: InkSet, + /// Spot inks past [`MAX_SPOT_INKS`]: listed, not separated. + pub unseparated: Vec, + pub width: u32, + pub height: u32, + /// The resolution the plates rendered at, after [`MAX_PLATE_PIXELS`]. + pub dpi: f32, + pub conversion: Conversion, + /// One plane per ink, in [`InkSet::iter`] order: the tint per pixel, row-major, 0 for no + /// ink and 255 for 100%. + pub planes: Vec>, +} + +impl Plates { + /// Ink `id`'s plane. + pub fn plane(&self, id: InkId) -> Option<&[u8]> { + self.planes.get(id.index()).map(Vec::as_slice) + } + + /// Ink `id`'s tint at pixel (`x`, `y`), 0–255. + pub fn tint(&self, id: InkId, x: u32, y: u32) -> Option { + if x >= self.width || y >= self.height { + return None; + } + self.plane(id)?.get(y as usize * self.width as usize + x as usize).copied() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Open(#[from] OpenError), + #[error("page {0} does not exist")] + NoPage(usize), + #[error("the resolution must be positive and finite")] + InvalidDpi, + #[error("the fallback profile can't be a printing condition: {0}")] + Profile(#[from] ProfileError), + /// After a [`raster::Error::BackendPanic`] the document is dropped; nothing else is kept. + #[error(transparent)] + Render(#[from] raster::Error), +} + +/// Separates page `page` (from 0) of `pdf` at `dpi`, reduced to fit [`MAX_PLATE_PIXELS`] and +/// the renderer's side limit: one plate per ink, the four process inks and up to +/// [`MAX_SPOT_INKS`] spot inks the page paints. Spot inks only soft masks use are left out +/// (masks don't print). Never panics. +pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result { + if !dpi.is_finite() || dpi <= 0.0 { + return Err(Error::InvalidDpi); + } + let doc = open_slice(pdf, None)?; + let (simulation, conversion) = match catch_unwind(AssertUnwindSafe(|| output_intent(&doc, page))) { + Ok(Some(found)) => (Some(found.0), found.1), + Ok(None) | Err(_) => fallback(&opts.fallback)?, + }; + let pages = doc.pages(); + let p = pages.get(page).ok_or(Error::NoPage(page))?; + let (w, h) = p.render_dimensions(); + let scale = plate_scale(w, h, dpi / 72.0); + let cache = RenderCache::new(); + + let log = Arc::new(Mutex::new(SpotLog::default())); + let discover = InterpreterSettings { ink_sink: Some(spot_sink(&log)), ..Default::default() }; + render(p, &cache, &discover, DISCOVERY_SCALE.min(scale))?; + let spots = std::mem::take(&mut log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).order); + + let mut inks = InkSet::new(); + let mut separated: Vec = PdfInk::PROCESS.to_vec(); + let mut unseparated = Vec::new(); + for (ink, swatch) in spots { + if separated.len() >= 4 + MAX_SPOT_INKS { + unseparated.push(ink.name().to_string()); + continue; + } + let alternate = Alternate::Lab(srgb8_to_lab(swatch).unwrap_or([0.0; 3])); + let spot = Ink::Spot(Spot { name: ink.name().to_string(), alternate, solidity: None }); + // A name that normalises to one already listed (`A#2341` and `AA`) has no plate of its + // own. + match inks.add(spot) { + Some(id) if id.index() == separated.len() => separated.push(ink), + _ => unseparated.push(ink.name().to_string()), + } + } + + let mut size = None; + let mut planes = Vec::with_capacity(separated.len()); + for ink in separated { + let plate = Plate { ink, overprint: opts.overprint, simulation: simulation.clone() }; + let settings = InterpreterSettings { plate: Some(plate), ..Default::default() }; + let film = render(p, &cache, &settings, scale)?; + size = Some((film.width, film.height)); + // The film is gray on white: its red channel is the paper left uncovered. + planes.push(film.bytes().as_chunks::<4>().0.iter().map(|[red, ..]| 255 - red).collect()); + } + let (width, height) = size.unwrap_or((0, 0)); + Ok(Plates { inks, unseparated, width, height, dpi: scale * 72.0, conversion, planes }) +} + +fn render<'a>( + page: &'a craft_pdf::syntax::page::Page<'a>, + cache: &RenderCache<'a>, + settings: &InterpreterSettings, + scale: f32, +) -> Result { + // The plate's gray film must keep its tints: no colour replacement. + let options = raster::Options { scale: [scale, scale], background: [255; 4], color_replacement: None, ..Default::default() }; + Ok(raster::render(page, cache, settings, &options)?) +} + +/// The scale a `w` × `h` pt page separates at: `scale`, within [`MAX_PLATE_PIXELS`] and +/// [`MAX_SIDE`]. +fn plate_scale(w: f32, h: f32, scale: f32) -> f32 { + let (w, h) = (w.max(1.0), h.max(1.0)); + let by_area = (MAX_PLATE_PIXELS / (w * h)).sqrt(); + let by_side = MAX_SIDE as f32 / w.max(h); + let s = scale.min(by_area).min(by_side); + if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE } +} + +fn fallback(fallback: &Fallback) -> Result<(Option, Conversion), Error> { + let (conversion, icc): (CmsConversion, &[u8]) = match fallback { + Fallback::DeviceFormulas => return Ok((None, Conversion::DeviceFormulas)), + Fallback::Fogra39 => (CmsConversion::fogra39(), craft_cms::builtin::COATED_FOGRA39_ICC), + Fallback::Profile(icc) => (CmsConversion::new(icc)?, icc), + }; + let simulation = conversion.simulation(icc); + let profile = simulation.description().unwrap_or("unnamed CMYK profile").to_string(); + Ok((Some(simulation), Conversion::Fallback { profile })) +} + +/// Spot inks reported while a page renders, in the order first painted, each with its sRGB +/// appearance at 100%. +#[derive(Default)] +struct SpotLog { + order: Vec<(PdfInk, [u8; 3])>, + seen: HashSet, +} + +fn spot_sink(log: &Arc>) -> InkSinkFn { + let log = log.clone(); + Arc::new(move |ink: &PdfInk, appearance: &dyn Fn(f32) -> [u8; 3]| { + let mut log = log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if log.seen.contains(ink) || log.order.len() >= MAX_LISTED_SPOTS { + return; + } + log.seen.insert(ink.clone()); + log.order.push((ink.clone(), appearance(1.0))); + }) +} + +/// A text string entry of `dict`, without control characters and at most [`MAX_CONDITION`] +/// characters long; `None` when missing or blank. +fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option { + let s = dict.get::>(key)?; + let text: String = pdf_text(s.as_bytes()).chars().filter(|c| !c.is_control()).take(MAX_CONDITION).collect(); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +/// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else +/// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation). +fn pdf_text(bytes: &[u8]) -> String { + if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) { + let units: Vec = utf16.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + return String::from_utf16_lossy(&units); + } + if let Some(utf8) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(utf8).into_owned(); + } + bytes.iter().map(|b| char::from(*b)).collect() +} + +/// The printing condition of page `page` (ISO 32000-2 §14.11.5): of the page's own +/// `/OutputIntents` (PDF 2.0), else the catalog's, the first whose `/DestOutputProfile` is a +/// CMYK profile craft-cms converts into, a `GTS_PDFX` intent before the others. +fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> { + fn list<'a>(intents: Option>) -> Vec> { + intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect() + } + let xref = pdf.xref(); + let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS))); + if listed.is_empty() { + listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS))); + } + // A stable sort: the PDF/X intent first, the others in their order. + listed.sort_by_key(|d| d.get::>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX")); + listed.iter().find_map(|intent| { + let icc = intent.get::>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?; + let simulation = CmsConversion::new(&icc).ok()?.simulation(&icc); + let condition = text_entry(intent, OUTPUT_CONDITION_IDENTIFIER); + let profile = simulation + .description() + .map(str::to_string) + .or_else(|| text_entry(intent, OUTPUT_CONDITION)) + .or_else(|| condition.clone()) + .unwrap_or_else(|| "unnamed CMYK profile".to_string()); + Some((simulation, Conversion::OutputIntent { profile, condition })) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plates_fit_the_pixel_and_side_caps() { + assert_eq!(plate_scale(40.0, 40.0, 1.0), 1.0); + // A4 at 2400 dpi would be 22 000 × 31 000 pixels. + let s = plate_scale(595.0, 842.0, 2400.0 / 72.0); + assert!((595.0 * s) * (842.0 * s) <= MAX_PLATE_PIXELS * 1.0001); + // A 1 × 10 000 pt strip fits the area cap but not the renderer's side limit. + assert!(10_000.0 * plate_scale(1.0, 10_000.0, 1.0) <= MAX_SIDE as f32); + assert!(plate_scale(0.0, f32::NAN, 1.0) > 0.0); + } +} diff --git a/crates/separations/tests/plates.rs b/crates/separations/tests/plates.rs new file mode 100644 index 00000000..fb035e57 --- /dev/null +++ b/crates/separations/tests/plates.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; + +use craft_cms::{Intent, Profile, Transform, builtin}; +use craft_separations::{Alternate, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; + +/// A 40 × 40 pt page with `content` and `resources`, `catalog` entries, and `objects` +/// numbered from 5 (each a whole object body). +fn page_pdf(content: &str, resources: &str, catalog: &str, objects: &[Vec]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + out.extend_from_slice(format!("1 0 obj << /Type /Catalog /Pages 2 0 R {catalog} >> endobj\n").as_bytes()); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + out.extend_from_slice( + format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(), + ); + out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + for (i, body) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b" endobj\n"); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +/// A spot colour whose alternate is a yellowish CMYK. +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]"; + +/// Black over the page, Gold at 50% in the middle square, overprinting when `gs` sets `op`. +fn black_and_gold(gs: &str) -> Vec { + let resources = format!("/ColorSpace << /CS0 {GOLD} >> /ExtGState << /OP << /op true >> >>"); + page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} /CS0 cs 0.5 scn 10 10 20 20 re f"), &resources, "", &[]) +} + +fn black() -> InkId { + InkId::process(Process::Black) +} + +#[test] +fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() { + let p = plates(&black_and_gold(""), 0, 72.0, &Options::default()).unwrap(); + assert_eq!((p.width, p.height, p.dpi), (40, 40, 72.0)); + let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]); + assert_eq!(p.planes.len(), 5); + assert!(p.unseparated.is_empty()); + let gold = p.inks.lookup("Gold").unwrap(); + let Some(Ink::Spot(spot)) = p.inks.get(gold) else { panic!("Gold is a spot ink") }; + let Alternate::Lab([l, _, b]) = spot.alternate else { panic!("a Lab alternate") }; + assert!(l > 60.0 && b > 40.0, "Gold looks yellow: {:?}", spot.alternate); + + let half = |t: Option| t.is_some_and(|t| t.abs_diff(128) <= 1); + assert!(half(p.tint(gold, 20, 20)), "Gold at 50%"); + assert_eq!(p.tint(black(), 20, 20), Some(0), "Gold knocks out black"); + assert_eq!(p.tint(black(), 2, 2), Some(255)); + assert_eq!(p.tint(gold, 2, 2), Some(0)); + for c in [Process::Cyan, Process::Magenta, Process::Yellow] { + assert!(p.plane(InkId::process(c)).unwrap().iter().all(|t| *t == 0), "{c:?} is empty"); + } + + let over = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options::default()).unwrap(); + assert!(half(over.tint(gold, 20, 20))); + assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted"); + let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap(); + assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out"); +} + +/// 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_separations::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_colour_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_colours_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"); +} From 126440b274da8a0e3f734ec74e6207de4badb337 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:15:36 +0000 Subject: [PATCH 06/11] separations: test craft-separations in the PDF workflow on Ubuntu --- .github/workflows/pdf.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index e0d9fcf1..51ccf4f2 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -3,9 +3,9 @@ name: Shared PDF on: push: branches: [main] - paths: ["crates/pdf/**", "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/separations/**", "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/**", "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/separations/**", "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: @@ -46,6 +46,11 @@ jobs: - run: cargo clippy -p craft-pdf --all-targets --all-features --locked -- -D warnings - run: cargo test -p craft-pdf --all-features --locked - run: cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked + - name: Separations (plates through the vendored renderer) + if: matrix.os == 'ubuntu-latest' + run: | + cargo clippy -p craft-separations --all-targets --locked -- -D warnings + cargo test -p craft-separations --locked backends: name: PDF vendored contracts runs-on: ubuntu-latest From 3c6089bcfc41be5884fe03b3912ade87a0bb670e Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:22:29 +0000 Subject: [PATCH 07/11] separations: coverage, TAC regions and a composite preview from plates Per-ink coverage at a pixel and over a page, TAC per pixel, the pixels and connected regions over a TAC limit, and an sRGB composite of chosen plates: process plates through the printing condition, spot plates multiplied over them by their alternate's appearance. Ported from the data parts of pdfcraft #2770 separations.rs. Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> --- crates/separations/src/cms.rs | 18 ++ crates/separations/src/composite.rs | 134 +++++++++++++ crates/separations/src/coverage.rs | 295 ++++++++++++++++++++++++++++ crates/separations/src/lib.rs | 8 +- crates/separations/tests/plates.rs | 15 +- 5 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 crates/separations/src/composite.rs create mode 100644 crates/separations/src/coverage.rs diff --git a/crates/separations/src/cms.rs b/crates/separations/src/cms.rs index bf0d8d85..88b3c592 100644 --- a/crates/separations/src/cms.rs +++ b/crates/separations/src/cms.rs @@ -141,3 +141,21 @@ pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { 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) colours, relative colorimetric, three values per colour in +/// both; `None` if craft-cms can't convert or the lengths disagree. +pub(crate) fn lab_to_srgb8(lab: &[f32], rgb: &mut [u8]) -> Option<()> { + static T: OnceLock>> = OnceLock::new(); + if !lab.len().is_multiple_of(3) || lab.len() != rgb.len() { + return None; + } + let t = CmsConversion::transform(&T, Builtin::LabD50.profile(), builtin::srgb(), false)?; + // The Lab D50 identity profile's device values (ICC v4 Lab encoding). + let encoded: Vec = lab.as_chunks::<3>().0.iter().flat_map(|[l, a, b]| [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]).collect(); + let mut out = vec![0.0_f32; encoded.len()]; + t.convert_f32(&encoded, 3, &mut out, 3, false).ok()?; + for (byte, v) in rgb.iter_mut().zip(out) { + *byte = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + } + Some(()) +} diff --git a/crates/separations/src/composite.rs b/crates/separations/src/composite.rs new file mode 100644 index 00000000..ab606245 --- /dev/null +++ b/crates/separations/src/composite.rs @@ -0,0 +1,134 @@ +//! A colour 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` colour space's usual tint transform does. +fn appearance(alternate: Alternate, cms: &dyn CmykConversion) -> Option> { + let mut rgb = vec![0_u8; 256 * 3]; + match alternate { + Alternate::Lab([l, a, b]) => { + let lab: Vec = (0..=255_u8) + .flat_map(|t| { + let t = f32::from(t) / 255.0; + [100.0 + t * (l - 100.0), t * a, t * b] + }) + .collect(); + lab_to_srgb8(&lab, &mut rgb)?; + } + Alternate::Cmyk(solid) => { + let cmyk: Vec = (0..=255_u8) + .flat_map(|t| solid.map(|c| (f32::from(t) * if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }).round() as u8)) + .collect(); + to_srgb(cms, &cmyk, &mut rgb); + } + } + Some(rgb.as_chunks::<3>().0.to_vec()) +} + +impl Plates { + /// The enabled inks as they print, as sRGB8 (three bytes per pixel, row-major, + /// `width × height` pixels). `enabled` is indexed by [`InkId::index`]; inks past its end + /// are enabled, so `&[]` shows every ink. + /// + /// The process plates convert through `cms` ([`CmsConversion`](crate::CmsConversion) for + /// the printing condition the plates were made for, see [`Plates::conversion`]), relative + /// colorimetric with black point compensation, or through the bundled CMYK profile when + /// `cms` can't. Each spot plate is then multiplied over them by the spot's appearance at + /// its tint, from its alternate. Spots whose Lab alternate craft-cms can't convert are + /// left out. + pub fn composite(&self, enabled: &[bool], cms: &dyn CmykConversion) -> Vec { + let n = (self.width as usize).saturating_mul(self.height as usize); + let mut rgb = vec![255_u8; n.saturating_mul(3)]; + let process: Vec> = Process::ALL + .iter() + .map(|p| { + let id = InkId::process(*p); + self.plane(id).filter(|_| on(enabled, id.index())) + }) + .collect(); + let mut cmyk = Vec::with_capacity(CHUNK * 4); + for (c, out) in rgb.chunks_mut(CHUNK * 3).enumerate() { + let start = c * CHUNK; + let len = out.len() / 3; + cmyk.clear(); + for i in start..start + len { + cmyk.extend(process.iter().map(|plane| plane.and_then(|p| p.get(i)).copied().unwrap_or(0))); + } + to_srgb(cms, &cmyk, out); + } + + for (id, spot) in self.inks.spots().filter(|(id, _)| on(enabled, id.index())) { + let Some(plane) = self.plane(id).filter(|p| p.iter().any(|t| *t > 0)) else { continue }; + let Some(ramp) = appearance(spot.alternate, cms) else { continue }; + for (px, tint) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(plane) { + if let Some(ink) = ramp.get(usize::from(*tint)).filter(|_| *tint > 0) { + for (c, s) in px.iter_mut().zip(ink) { + *c = ((u16::from(*c) * u16::from(*s) + 127) / 255) as u8; + } + } + } + } + rgb + } +} + +#[cfg(test)] +mod tests { + use crate::CmsConversion; + use crate::cms::srgb8_to_lab; + use crate::coverage::tests::synthetic; + + fn near(got: &[u8], expected: [u8; 3], tolerance: u8) -> bool { + got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= tolerance) + } + + #[test] + fn a_black_and_gold_composite_multiplies_gold_over_black() { + const GOLD: [u8; 3] = [212, 175, 55]; + let lab = srgb8_to_lab(GOLD).unwrap(); + // Black alone, Gold alone, Gold over black, Gold at 50%. + let p = synthetic(4, 1, lab, [vec![0; 4], vec![0; 4], vec![0; 4], vec![255, 0, 255, 0], vec![0, 255, 255, 128]]); + let cms = CmsConversion::fogra39(); + let rgb = p.composite(&[], &cms); + assert_eq!(rgb.len(), 12); + + let black = &rgb[0..3]; + let (lo, hi) = (black.iter().min().unwrap(), black.iter().max().unwrap()); + assert!(*hi < 80 && hi - lo < 12, "Coated FOGRA39 black is a dark neutral: {black:?}"); + assert!(near(&rgb[3..6], GOLD, 2), "Gold: {:?}", &rgb[3..6]); + let over: Vec = black.iter().zip(GOLD).map(|(k, g)| ((u16::from(*k) * u16::from(g) + 127) / 255) as u8).collect(); + assert!(near(&rgb[6..9], [over[0], over[1], over[2]], 2), "Gold over black: {:?}", &rgb[6..9]); + let half = &rgb[9..12]; + assert!(half.iter().zip(GOLD).all(|(h, g)| *h > g && *h < 255) && half[2] > 110 && half[2] < 200, "Gold at 50%: {half:?}"); + + let no_black = p.composite(&[true, true, true, false], &cms); + assert!(near(&no_black[0..3], [255, 255, 255], 1), "paper: {:?}", &no_black[0..3]); + assert!(near(&no_black[6..9], GOLD, 2)); + let no_gold = p.composite(&[true, true, true, true, false], &cms); + assert_eq!(&no_gold[6..9], black); + } +} diff --git a/crates/separations/src/coverage.rs b/crates/separations/src/coverage.rs new file mode 100644 index 00000000..253c18ff --- /dev/null +++ b/crates/separations/src/coverage.rs @@ -0,0 +1,295 @@ +//! Ink coverage read from plates: per ink at a point and over a page, total area coverage +//! (TAC) per pixel, and the pixels over a TAC limit. +//! +//! Inks are chosen with an `enabled` list indexed by [`InkId::index`], as a Separations +//! Preview's toggles hold them: inks past its end are enabled, so `&[]` enables every ink. +//! Percentages are of one ink's 100%, so a TAC runs to 100 × the number of inks. + +use crate::{InkId, Plates}; + +/// The most regions [`Above::regions`] lists. +pub const MAX_REGIONS: usize = 256; + +/// Coverage at one pixel, in percent. +#[derive(Clone, Debug, PartialEq)] +pub struct Coverage { + /// Each ink's tint, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The total area coverage: the sum of the enabled inks' tints. + pub total: f32, +} + +/// One ink's coverage over a page, in percent. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct InkStats { + /// The highest tint. + pub max: f32, + /// The mean tint over every pixel of the page. + pub mean: f32, + /// The share of the page the ink prints on (any tint above 0). + pub area: f32, +} + +/// A page's coverage figures, in percent. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Stats { + /// Each ink's figures, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The highest TAC of the enabled inks. + pub max_total: f32, + /// The first pixel (in row order) with [`max_total`](Self::max_total); `None` for an empty + /// page. + pub max_total_at: Option<(u32, u32)>, + /// The mean TAC of the enabled inks. + pub mean_total: f32, +} + +/// The pixels whose TAC is over a limit. +#[derive(Clone, Debug, PartialEq)] +pub struct Above { + pub width: u32, + pub height: u32, + /// One entry per pixel, row-major: `true` where the TAC is over the limit. + pub mask: Vec, + /// The number of pixels over the limit. + pub count: usize, +} + +/// A connected group of pixels over the limit (4-connected), with its bounding box in pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Region { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + /// The pixels over the limit in the region. + pub pixels: u32, +} + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +fn percent(tint: u64) -> f32 { + (tint as f64 * 100.0 / 255.0) as f32 +} + +impl Plates { + fn pixels(&self) -> usize { + (self.width as usize).saturating_mul(self.height as usize) + } + + fn index_of(&self, x: u32, y: u32) -> Option { + (x < self.width && y < self.height).then(|| y as usize * self.width as usize + x as usize) + } + + fn enabled_planes<'a>(&'a self, enabled: &'a [bool]) -> impl Iterator> + 'a { + self.planes.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, p)| p) + } + + /// The pixel under a point given as fractions `0.0..1.0` of the page's width and height, + /// as a pointer over a preview gives it. + pub fn pixel_at(&self, u: f32, v: f32) -> Option<(u32, u32)> { + if !(0.0..1.0).contains(&u) || !(0.0..1.0).contains(&v) { + return None; + } + let x = ((u * self.width as f32) as u32).min(self.width.checked_sub(1)?); + let y = ((v * self.height as f32) as u32).min(self.height.checked_sub(1)?); + Some((x, y)) + } + + /// Each ink's tint and the TAC of the enabled inks at pixel (`x`, `y`). + pub fn coverage_at(&self, x: u32, y: u32, enabled: &[bool]) -> Option { + let at = self.index_of(x, y)?; + let tints: Vec = self.planes.iter().map(|p| p.get(at).copied().map_or(0, u64::from)).collect(); + let total = tints.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, t)| t).sum(); + Some(Coverage { inks: tints.into_iter().map(percent).collect(), total: percent(total) }) + } + + /// Ink `id`'s tint at pixel (`x`, `y`) in percent. + pub fn percent_at(&self, id: InkId, x: u32, y: u32) -> Option { + self.tint(id, x, y).map(|t| percent(u64::from(t))) + } + + /// The TAC of the enabled inks at every pixel, row-major, as a sum of tint bytes (255 per + /// ink at 100%). + fn totals(&self, enabled: &[bool]) -> Vec { + let mut totals = vec![0_u32; self.pixels()]; + for plane in self.enabled_planes(enabled) { + for (sum, tint) in totals.iter_mut().zip(plane) { + *sum = sum.saturating_add(u32::from(*tint)); + } + } + totals + } + + /// The TAC of the enabled inks at every pixel, row-major, in percent. + pub fn tac(&self, enabled: &[bool]) -> Vec { + self.totals(enabled).into_iter().map(|t| percent(u64::from(t))).collect() + } + + /// Each ink's figures over the page, and the TAC of the enabled inks. + pub fn stats(&self, enabled: &[bool]) -> Stats { + let n = self.pixels(); + let mean = |sum: u64| if n == 0 { 0.0 } else { percent(sum) / n as f32 }; + let inks = self + .planes + .iter() + .map(|plane| { + let plane = plane.get(..n).unwrap_or(plane); + let max = plane.iter().copied().max().unwrap_or(0); + let sum: u64 = plane.iter().map(|t| u64::from(*t)).sum(); + let inked = plane.iter().filter(|t| **t > 0).count(); + let area = if n == 0 { 0.0 } else { inked as f32 * 100.0 / n as f32 }; + InkStats { max: percent(u64::from(max)), mean: mean(sum), area } + }) + .collect(); + let totals = self.totals(enabled); + // The first pixel with the highest total: `max_by_key` keeps the last of equals. + let top = totals.iter().enumerate().rev().max_by_key(|(_, t)| **t); + let width = self.width as usize; + let max_total_at = top.and_then(|(at, _)| Some((u32::try_from(at % width).ok()?, u32::try_from(at / width).ok()?))); + Stats { + inks, + max_total: top.map_or(0.0, |(_, t)| percent(u64::from(*t))), + max_total_at, + mean_total: mean(totals.iter().map(|t| u64::from(*t)).sum()), + } + } + + /// The pixels whose TAC of the enabled inks is over `limit` percent (such as 300 for + /// coated paper). A limit that isn't finite marks nothing. + pub fn above(&self, limit: f32, enabled: &[bool]) -> Above { + let limit = if limit.is_finite() { limit.max(0.0) } else { f32::MAX }; + let mask: Vec = self.totals(enabled).into_iter().map(|t| percent(u64::from(t)) > limit).collect(); + let count = mask.iter().filter(|m| **m).count(); + Above { width: self.width, height: self.height, mask, count } + } +} + +impl Above { + /// The connected regions of marked pixels, largest first (ties in row order of their first + /// pixel), at most [`MAX_REGIONS`] of them. + pub fn regions(&self) -> Vec { + let (w, h) = (self.width as usize, self.height as usize); + if self.mask.len() != w.saturating_mul(h) { + return Vec::new(); + } + let mut seen = vec![false; self.mask.len()]; + let mut regions = Vec::new(); + let mut stack = Vec::new(); + for start in 0..self.mask.len() { + if !self.mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let (mut x0, mut y0, mut x1, mut y1, mut pixels) = (start % w, start / w, start % w, start / w, 0_u32); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + stack.push(start); + while let Some(at) = stack.pop() { + let (x, y) = (at % w, at / w); + pixels = pixels.saturating_add(1); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + let next = [(x > 0).then(|| at - 1), (x + 1 < w).then(|| at + 1), (y > 0).then(|| at - w), (y + 1 < h).then(|| at + w)]; + for n in next.into_iter().flatten() { + if self.mask.get(n).copied().unwrap_or(false) + && let Some(s) = seen.get_mut(n).filter(|s| !**s) + { + *s = true; + stack.push(n); + } + } + } + // The mask is at most `width × height` with both `u32`, so coordinates fit. + regions.push(Region { x: x0 as u32, y: y0 as u32, width: (x1 - x0 + 1) as u32, height: (y1 - y0 + 1) as u32, pixels }); + } + regions.sort_by_key(|r| std::cmp::Reverse(r.pixels)); + regions.truncate(MAX_REGIONS); + regions + } + + /// Paints the marked pixels of an sRGB8 image of the same size (three bytes per pixel, + /// such as [`Plates::composite`]'s) in `colour`, as a TAC warning. + pub fn paint(&self, rgb: &mut [u8], colour: [u8; 3]) { + for (px, _) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(&self.mask).filter(|(_, m)| **m) { + *px = colour; + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{Alternate, Conversion, Ink, InkSet, Spot}; + + /// Plates of `width × height` pixels for the process inks and a Gold spot (Lab alternate + /// `gold`), from planes given in `C M Y K Gold` order. + pub(crate) fn synthetic(width: u32, height: u32, gold: [f32; 3], planes: [Vec; 5]) -> Plates { + let mut inks = InkSet::new(); + inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Lab(gold), solidity: None })); + Plates { inks, unseparated: Vec::new(), width, height, dpi: 72.0, conversion: Conversion::DeviceFormulas, planes: planes.to_vec() } + } + + fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 + } + + #[test] + fn coverage_tac_and_regions_over_a_limit() { + // 3 × 2 pixels: rich black with Gold at (0,0), (1,0) (Gold at 50%) and (2,1); black + // alone at (2,0). + let p = synthetic( + 3, + 2, + [70.0, 5.0, 60.0], + [vec![0; 6], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 255, 0, 0, 255], vec![255, 128, 0, 0, 0, 255]], + ); + let at = p.coverage_at(1, 0, &[]).unwrap(); + assert!(close(at.inks[4], 50.196) && close(at.total, 350.196), "{at:?}"); + assert!(close(p.coverage_at(1, 0, &[true, true, true, true, false]).unwrap().total, 300.0), "Gold hidden"); + assert_eq!(p.coverage_at(3, 0, &[]), None); + assert_eq!(p.pixel_at(0.99, 0.6), Some((2, 1))); + assert_eq!(p.pixel_at(1.0, 0.0), None); + assert!(close(p.tac(&[])[2], 100.0)); + + let s = p.stats(&[]); + assert!(close(s.max_total, 400.0) && s.max_total_at == Some((0, 0)), "{s:?}"); + assert!(close(s.mean_total, 3188.0 / 1530.0 * 100.0), "{s:?}"); + let k = s.inks[3]; + assert!(close(k.max, 100.0) && close(k.mean, 66.667) && close(k.area, 66.667), "{k:?}"); + let gold = s.inks[4]; + assert!(close(gold.mean, 638.0 / 1530.0 * 100.0) && close(gold.area, 50.0), "{gold:?}"); + assert_eq!(s.inks[0], InkStats::default()); + + let over = p.above(300.0, &[]); + assert_eq!(over.count, 3); + assert_eq!(over.mask, [true, true, false, false, false, true]); + let regions = over.regions(); + assert_eq!(regions, [Region { x: 0, y: 0, width: 2, height: 1, pixels: 2 }, Region { x: 2, y: 1, width: 1, height: 1, pixels: 1 }]); + assert_eq!(p.above(300.0, &[true, true, true, true, false]).count, 0, "exactly at the limit is not over it"); + assert_eq!(p.above(f32::NAN, &[]).count, 0); + + let mut rgb = vec![0_u8; 18]; + over.paint(&mut rgb, [255, 0, 255]); + assert_eq!(&rgb[..6], [255, 0, 255, 255, 0, 255]); + assert_eq!(&rgb[6..9], [0, 0, 0]); + } + + #[test] + fn regions_join_four_connected_pixels_and_keep_the_largest() { + // A ring around an unmarked centre is one region; the diagonal neighbour is another. + #[rustfmt::skip] + let mask = vec![ + true, true, true, false, + true, false, true, false, + true, true, true, false, + false, false, false, true, + ]; + let above = Above { width: 4, height: 4, count: 9, mask }; + assert_eq!(above.regions(), [Region { x: 0, y: 0, width: 3, height: 3, pixels: 8 }, Region { x: 3, y: 3, width: 1, height: 1, pixels: 1 }]); + let empty = Plates { width: 0, height: 0, planes: Vec::new(), ..synthetic(0, 0, [0.0; 3], Default::default()) }; + assert_eq!(empty.stats(&[]).max_total_at, None); + assert!(empty.above(10.0, &[]).regions().is_empty()); + } +} diff --git a/crates/separations/src/lib.rs b/crates/separations/src/lib.rs index 73a131c7..41f0d610 100644 --- a/crates/separations/src/lib.rs +++ b/crates/separations/src/lib.rs @@ -1,12 +1,16 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -//! Print separations for the Craft apps: an ink model shared by the writers, plates rendered -//! from a PDF, and process colour conversion through craft-cms. See the README. +//! Print separations for the Craft apps: an ink model and ink manager shared by the writers, +//! plates rendered from a PDF, coverage and a colour preview read from the plates, and process +//! colour conversion through craft-cms. See the README. pub mod cms; +pub mod composite; +pub mod coverage; pub mod ink; pub mod plates; pub use cms::{CmsConversion, ProfileError}; +pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name}; pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/separations/tests/plates.rs b/crates/separations/tests/plates.rs index fb035e57..85c5737f 100644 --- a/crates/separations/tests/plates.rs +++ b/crates/separations/tests/plates.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use craft_cms::{Intent, Profile, Transform, builtin}; -use craft_separations::{Alternate, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; +use craft_separations::{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). @@ -69,6 +69,19 @@ fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() { 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. From 9e2c629f257606188a3791ddd68d6f77c3af028c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:22:29 +0000 Subject: [PATCH 08/11] separations: ink manager on InkSet Aliases (a spot printed on another ink's plate), spot to process and all spots to process, with resolve applying both to a colour. The README describes the coverage, composite and ink manager API and how the apps use it for a Separations Preview and an Ink Manager. --- crates/separations/README.md | 96 ++++++++++++++- crates/separations/src/ink.rs | 226 +++++++++++++++++++++++++++++++++- 2 files changed, 317 insertions(+), 5 deletions(-) diff --git a/crates/separations/README.md b/crates/separations/README.md index 9eef7eb8..de5da196 100644 --- a/crates/separations/README.md +++ b/crates/separations/README.md @@ -3,10 +3,14 @@ Print separations for the Craft apps: - `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; `InkColor` is a colour 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. + (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 colour 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 colour conversion into a CMYK printing condition through craft-cms. ## Plates from an exported PDF @@ -68,9 +72,93 @@ The renderer's side limit (`craft_pdf::raster::MAX_SIDE`) also lowers the resolu - 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, colour)` 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_separations::{CmsConversion, InkId}; + +let cms = CmsConversion::fogra39(); +let mut enabled = vec![true; p.inks.len()]; +enabled[InkId::process(craft_separations::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 colour, 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 colour 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 colour 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 colour 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 + recolours 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 colours 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 and the output-intent handling come from +- 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 diff --git a/crates/separations/src/ink.rs b/crates/separations/src/ink.rs index 77da40b7..c3c4c739 100644 --- a/crates/separations/src/ink.rs +++ b/crates/separations/src/ink.rs @@ -1,6 +1,12 @@ //! The ink model: which inks a job prints with, and colours 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)] @@ -97,9 +103,17 @@ impl InkId { /// 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 colour +/// ([`set_to_process`](Self::set_to_process), [`set_all_to_process`](Self::set_all_to_process)). +/// [`resolve`](Self::resolve) applies both to a colour. #[derive(Clone, Debug, PartialEq)] pub struct InkSet { inks: Vec, + aliases: BTreeMap, + to_process: BTreeSet, + all_to_process: bool, } impl Default for InkSet { @@ -111,7 +125,12 @@ impl Default for InkSet { impl InkSet { /// The four process inks. pub fn new() -> Self { - Self { inks: Process::ALL.into_iter().map(Ink::Process).collect() } + 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 @@ -174,6 +193,149 @@ impl InkSet { 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 colour; `resolve` follows the chain. + /// `to == from` removes the alias. Returns `false`, changing nothing, when `from` is not a + /// spot ink of the set, `to` is not in the set, or the alias would make a loop. + pub fn alias(&mut self, from: InkId, to: InkId) -> bool { + if self.spot(from).is_none() || self.get(to).is_none() { + return false; + } + if from == to { + self.aliases.remove(&from); + return true; + } + if self.plate_chain(to).any(|id| id == from) { + return false; + } + self.aliases.insert(from, to); + true + } + + /// The ink spot ink `from` is aliased to, if any. + pub fn alias_of(&self, from: InkId) -> Option { + self.aliases.get(&from).copied() + } + + /// Prints spot ink `ink` as process colour (`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 colour ("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 colour when a colour names it directly (an + /// alias of it is followed first, see [`plate_of`](Self::plate_of)). + pub fn is_to_process(&self, ink: InkId) -> bool { + self.spot(ink).is_some() && (self.all_to_process || self.to_process.contains(&ink)) + } + + /// `id`, then each ink it is aliased to in turn. Stops after as many steps as there are + /// inks, so a loop can't make it endless. + fn plate_chain(&self, id: InkId) -> impl Iterator + '_ { + std::iter::successors(Some(id), |id| self.alias_of(*id)).take(self.inks.len()) + } + + /// The ink whose plate a tint of `id` prints on after aliases: `id` itself, the end of its + /// alias chain, or `None` when that ink prints as process colour (or `id` is not in the + /// set). + pub fn plate_of(&self, id: InkId) -> Option { + self.get(id)?; + let end = self.plate_chain(id).last()?; + (!self.is_to_process(end)).then_some(end) + } + + /// Spot ink `id` at 100% as process colour, `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 undercolour removal). + /// `None` for process inks, inks not in the set, and Lab colours 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 + /// colour 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 colour the result lists all four process inks first (a CMYK colour 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 colour as ink tints. @@ -189,6 +351,20 @@ pub enum InkColor { None, } +impl InkColor { + /// The colour as DeviceCMYK components when every tint is of a process ink, as + /// [`InkSet::resolve`] leaves a spot printed as process colour; 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 { @@ -252,6 +428,54 @@ mod tests { 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_colours() { + let mut set = InkSet::new(); + let gold = set.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap(); + let yellow = set.add(spot("Lab Yellow")).unwrap(); + let varnish = set.add(spot("Varnish")).unwrap(); + let k = InkId::process(Process::Black); + let cms = crate::CmsConversion::fogra39(); + let tints = |t: &[(InkId, f32)]| InkColor::Tints(t.to_vec()); + + let plain = tints(&[(gold, 0.5), (k, 1.0)]); + assert_eq!(set.resolve(&plain, &cms), plain, "no settings: unchanged"); + assert_eq!(set.resolve(&InkColor::All(1.0), &cms), InkColor::All(1.0)); + + assert!(set.alias(varnish, gold)); + assert_eq!((set.alias_of(varnish), set.plate_of(varnish)), (Some(gold), Some(gold))); + assert_eq!(set.resolve(&tints(&[(varnish, 0.5)]), &cms), tints(&[(gold, 0.5)])); + assert_eq!(set.resolve(&tints(&[(gold, 0.5), (varnish, 0.5)]), &cms), tints(&[(gold, 0.75)]), "one plate: tints overlap"); + assert!(!set.alias(gold, varnish), "a loop is refused"); + assert!(!set.alias(k, gold), "process inks aren't aliased"); + assert!(!set.alias(gold, InkId(99))); + + assert!(set.set_to_process(gold, true)); + assert!(!set.set_to_process(k, true)); + assert_eq!(set.plate_of(varnish), None, "aliased to a spot printed as process"); + assert!(close(set.resolve(&tints(&[(varnish, 0.5)]), &cms).as_cmyk(), [0.0, 0.15, 0.5, 0.0])); + let rich = set.resolve(&tints(&[(k, 0.5), (gold, 1.0)]), &cms); + assert!(close(rich.as_cmyk(), [0.0, 0.3, 1.0, 0.5]), "{rich:?}"); + let InkColor::Tints(mixed) = set.resolve(&tints(&[(yellow, 0.4), (gold, 1.0)]), &cms) else { panic!() }; + let order: Vec = mixed.iter().map(|(id, _)| *id).collect(); + assert_eq!(order[..4], Process::ALL.map(InkId::process), "process inks first"); + assert_eq!(mixed[4], (yellow, 0.4), "the spot left on its plate"); + + set.set_all_to_process(true); + assert!(set.is_to_process(yellow) && !set.is_to_process(k)); + let lab = set.resolve(&tints(&[(yellow, 1.0)]), &cms).as_cmyk().unwrap(); + assert!(lab[2] > 0.4 && lab[2] > lab[0] && lab[3] < 0.2, "a light yellow through FOGRA39: {lab:?}"); + assert!(set.alias(yellow, k)); + assert_eq!(set.resolve(&tints(&[(yellow, 0.5)]), &cms), tints(&[(k, 0.5)]), "aliases apply first"); + + assert!(set.alias(varnish, varnish)); + assert_eq!(set.alias_of(varnish), None); + } + #[test] fn malformed_escapes_stay_as_written() { assert_eq!(normalize_name("A#2"), "A#2"); From 602c915272a9072e93e69160baa018c0aad82c9b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:23:15 +0000 Subject: [PATCH 09/11] separations: US spelling in the coverage, composite and ink manager docs --- crates/separations/README.md | 16 +++++++-------- crates/separations/src/cms.rs | 2 +- crates/separations/src/composite.rs | 4 ++-- crates/separations/src/coverage.rs | 8 ++++---- crates/separations/src/ink.rs | 30 ++++++++++++++--------------- crates/separations/src/lib.rs | 4 ++-- 6 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/separations/README.md b/crates/separations/README.md index de5da196..cb9475c5 100644 --- a/crates/separations/README.md +++ b/crates/separations/README.md @@ -4,7 +4,7 @@ Print separations for the Craft apps: - `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 colour as ink tints (`Separation`, `DeviceN`, + (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. @@ -84,7 +84,7 @@ toggles: inks past its end count, so `&[]` counts every ink. Values are percent | `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, colour)` marks them on a preview | +| `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 @@ -119,19 +119,19 @@ let worst = over.regions().first().copied(); | 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 colour, from its alternate (`spot_cmyk`). | +| `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 colour lists the four process inks first, and `InkColor::as_cmyk` gives the CMYK when no spot is left. | +| `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 colour it emits, so the PDF carries the +- **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 colour that + 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 @@ -146,11 +146,11 @@ Apply it in two places: 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 - recolours the document and draws overprint as Darken. + 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 colours through it; the Separations + `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%". diff --git a/crates/separations/src/cms.rs b/crates/separations/src/cms.rs index 88b3c592..d164bedc 100644 --- a/crates/separations/src/cms.rs +++ b/crates/separations/src/cms.rs @@ -142,7 +142,7 @@ pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { Some([lab[0] * 100.0, lab[1] * 255.0 - 128.0, lab[2] * 255.0 - 128.0]) } -/// sRGB of CIE L\*a\*b\* (D50) colours, relative colorimetric, three values per colour in +/// sRGB of CIE L\*a\*b\* (D50) colors, relative colorimetric, three values per color in /// both; `None` if craft-cms can't convert or the lengths disagree. pub(crate) fn lab_to_srgb8(lab: &[f32], rgb: &mut [u8]) -> Option<()> { static T: OnceLock>> = OnceLock::new(); diff --git a/crates/separations/src/composite.rs b/crates/separations/src/composite.rs index ab606245..191eb0d8 100644 --- a/crates/separations/src/composite.rs +++ b/crates/separations/src/composite.rs @@ -1,4 +1,4 @@ -//! A colour preview composed from plates, as a Separations Preview shows the page. +//! 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 @@ -26,7 +26,7 @@ fn to_srgb(cms: &dyn CmykConversion, cmyk: &[u8], rgb: &mut [u8]) { /// 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` colour space's usual tint transform does. +/// `Separation` color space's usual tint transform does. fn appearance(alternate: Alternate, cms: &dyn CmykConversion) -> Option> { let mut rgb = vec![0_u8; 256 * 3]; match alternate { diff --git a/crates/separations/src/coverage.rs b/crates/separations/src/coverage.rs index 253c18ff..d88cad9a 100644 --- a/crates/separations/src/coverage.rs +++ b/crates/separations/src/coverage.rs @@ -210,10 +210,10 @@ impl Above { } /// Paints the marked pixels of an sRGB8 image of the same size (three bytes per pixel, - /// such as [`Plates::composite`]'s) in `colour`, as a TAC warning. - pub fn paint(&self, rgb: &mut [u8], colour: [u8; 3]) { + /// 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 = colour; + *px = color; } } } @@ -278,7 +278,7 @@ pub(crate) mod tests { #[test] fn regions_join_four_connected_pixels_and_keep_the_largest() { - // A ring around an unmarked centre is one region; the diagonal neighbour is another. + // A ring around an unmarked center is one region; the diagonal neighbor is another. #[rustfmt::skip] let mask = vec![ true, true, true, false, diff --git a/crates/separations/src/ink.rs b/crates/separations/src/ink.rs index c3c4c739..3f079243 100644 --- a/crates/separations/src/ink.rs +++ b/crates/separations/src/ink.rs @@ -105,9 +105,9 @@ impl InkId { /// 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 colour +/// 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 colour. +/// [`resolve`](Self::resolve) applies both to a color. #[derive(Clone, Debug, PartialEq)] pub struct InkSet { inks: Vec, @@ -202,7 +202,7 @@ impl InkSet { } /// 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 colour; `resolve` follows the chain. + /// 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 { @@ -225,7 +225,7 @@ impl InkSet { self.aliases.get(&from).copied() } - /// Prints spot ink `ink` as process colour (`on`), converted from its alternate, or on its + /// 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() { @@ -239,7 +239,7 @@ impl InkSet { true } - /// Prints every spot ink as process colour ("All Spots to Process"), whatever + /// 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) { @@ -250,7 +250,7 @@ impl InkSet { self.all_to_process } - /// Whether spot ink `ink` prints as process colour when a colour names it directly (an + /// 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)) @@ -263,7 +263,7 @@ impl InkSet { } /// 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 colour (or `id` is not in the + /// alias chain, or `None` when that ink prints as process color (or `id` is not in the /// set). pub fn plate_of(&self, id: InkId) -> Option { self.get(id)?; @@ -271,10 +271,10 @@ impl InkSet { (!self.is_to_process(end)).then_some(end) } - /// Spot ink `id` at 100% as process colour, `0.0..=1.0` per component: its CMYK alternate + /// 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 undercolour removal). - /// `None` for process inks, inks not in the set, and Lab colours craft-cms can't convert. + /// 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 })), @@ -293,9 +293,9 @@ impl InkSet { /// `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 - /// colour becomes that tint of its [`spot_cmyk`](Self::spot_cmyk). Tints that land on one + /// 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 colour the result lists all four process inks first (a CMYK colour if no spot + /// 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 { @@ -352,8 +352,8 @@ pub enum InkColor { } impl InkColor { - /// The colour as DeviceCMYK components when every tint is of a process ink, as - /// [`InkSet::resolve`] leaves a spot printed as process colour; process inks not listed + /// 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 }; @@ -433,7 +433,7 @@ mod tests { } #[test] - fn aliases_and_spots_to_process_resolve_colours() { + 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(); diff --git a/crates/separations/src/lib.rs b/crates/separations/src/lib.rs index 41f0d610..a1676f96 100644 --- a/crates/separations/src/lib.rs +++ b/crates/separations/src/lib.rs @@ -1,8 +1,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] //! Print separations for the Craft apps: an ink model and ink manager shared by the writers, -//! plates rendered from a PDF, coverage and a colour preview read from the plates, and process -//! colour conversion through craft-cms. See the README. +//! 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; From 2c80b65e9857c2bb5add8dbc585a5da2ae40f474 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:25:47 +0000 Subject: [PATCH 10/11] separations: use US spelling --- crates/pdf/tests/knockout.rs | 2 +- crates/pdf/tests/plates.rs | 32 +++++++++++------------ crates/pdf/tests/support/synthetic_icc.rs | 2 +- crates/separations/README.md | 6 ++--- crates/separations/src/cms.rs | 8 +++--- crates/separations/src/ink.rs | 18 ++++++------- crates/separations/src/plates.rs | 14 +++++----- crates/separations/tests/plates.rs | 6 ++--- vendor/README.craft-reader.md | 12 ++++----- vendor/hayro-interpret/SOURCE.json | 2 +- vendor/hayro-interpret/src/color.rs | 30 ++++++++++----------- 11 files changed, 66 insertions(+), 66 deletions(-) diff --git a/crates/pdf/tests/knockout.rs b/crates/pdf/tests/knockout.rs index e1dbf437..039674b9 100644 --- a/crates/pdf/tests/knockout.rs +++ b/crates/pdf/tests/knockout.rs @@ -138,7 +138,7 @@ fn knockout_groups_replace_what_their_elements_cover() { // 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 - // colour also depends on its fill opacity (pdfcraft #2975), so only "blue was drawn here" is + // 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"); diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs index 90bee344..632018c7 100644 --- a/crates/pdf/tests/plates.rs +++ b/crates/pdf/tests/plates.rs @@ -86,12 +86,12 @@ fn inks(spots: &[&str]) -> Vec { Ink::PROCESS.into_iter().chain(spots.iter().map(|s| spot(s))).collect() } -/// The process plates and Gold, overprint honoured. +/// The process plates and Gold, overprint honored. fn separate(pdf: &[u8]) -> Plates { plates(pdf, &inks(&["Gold"]), true, None) } -/// A spot colour whose alternate is a yellowish CMYK. +/// 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 { @@ -109,15 +109,15 @@ fn device_cmyk_puts_each_component_on_its_own_plate() { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn a_spot_colour_paints_its_own_plate_and_knocks_out_the_process_plates() { +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 colour"); + 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_colour_does_not_specify() { +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"); @@ -147,7 +147,7 @@ fn nonzero_overprint_mode_leaves_zero_device_cmyk_components() { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn gray_and_rgb_are_process_colour_and_never_take_the_nonzero_overprint_mode() { +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"); @@ -172,7 +172,7 @@ fn the_all_colorant_paints_every_plate_and_none_paints_nothing() { let resources = format!("/ColorSpace << /CS0 {GOLD} /Reg [/Separation /All {gray}] /No [/Separation /None {gray}] >>"); let content = "/CS0 cs 0.2 scn 0 0 40 40 re f /Reg cs 1 scn 0 0 20 40 re f /No cs 1 scn 20 0 20 40 re f"; let s = separate(&page_pdf(content, &resources, &[])); - assert_eq!(s.at(10, 20), [100, 100, 100, 100, 100], "registration colour"); + assert_eq!(s.at(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"); } @@ -194,7 +194,7 @@ fn images_are_separated_sample_by_sample() { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn a_spot_colour_image_overprints_like_any_paint() { +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)); @@ -204,7 +204,7 @@ fn a_spot_colour_image_overprints_like_any_paint() { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn shadings_are_separated_through_their_colour_space() { +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] >>" ); @@ -216,16 +216,16 @@ fn shadings_are_separated_through_their_colour_space() { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn an_uncoloured_pattern_paints_the_film_of_the_colour_it_is_used_with() { +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 colour, knocking out black"); + 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_colours() { +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"); @@ -263,12 +263,12 @@ fn the_ink_sink_reports_spot_inks_of_patterns_and_images_but_not_of_soft_masks() let seen = seen.lock().unwrap(); let names: Vec<&str> = seen.iter().map(|(ink, _)| ink.name()).collect(); assert_eq!(names, ["Silver", "Gold"], "in the order first painted; a mask's ink doesn't print"); - assert_eq!(seen[0].1, [128, 128, 128], "Silver at 100% is its alternate colour"); + 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_colour_spaces_and_overprint_parameters_draw_what_they_can() { +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); @@ -343,7 +343,7 @@ fn device_gray_stays_on_the_black_plate_under_a_simulation() { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn cie_and_icc_colours_convert_through_the_profile_relative_to_their_white() { +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"; @@ -426,7 +426,7 @@ impl CmykConversion for Fixed { #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -fn a_callers_cmyk_conversion_separates_process_colour() { +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))); diff --git a/crates/pdf/tests/support/synthetic_icc.rs b/crates/pdf/tests/support/synthetic_icc.rs index f81ceca0..97aeb30a 100644 --- a/crates/pdf/tests/support/synthetic_icc.rs +++ b/crates/pdf/tests/support/synthetic_icc.rs @@ -17,7 +17,7 @@ #![allow(dead_code)] -/// Grid points per input of the BToA table: fine enough that colours a little off the neutral +/// 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; diff --git a/crates/separations/README.md b/crates/separations/README.md index cb9475c5..a90df300 100644 --- a/crates/separations/README.md +++ b/crates/separations/README.md @@ -11,7 +11,7 @@ Print separations for the Craft apps: - `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 colour conversion into a CMYK printing condition through craft-cms. +- `cms`: process color conversion into a CMYK printing condition through craft-cms. ## Plates from an exported PDF @@ -44,9 +44,9 @@ Inks are found by a low-resolution pass that records each spot ink a paint uses, 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 colour +### Process color -RGB, CIE-based and ICCBased colours become CMYK through, in order: +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; diff --git a/crates/separations/src/cms.rs b/crates/separations/src/cms.rs index d164bedc..103d861b 100644 --- a/crates/separations/src/cms.rs +++ b/crates/separations/src/cms.rs @@ -1,11 +1,11 @@ -//! Process colour conversion for plate mode through craft-cms. +//! 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 colour into a CMYK printing condition with craft-cms: relative +/// 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. @@ -55,7 +55,7 @@ impl CmsConversion { } /// A plate-mode printing condition converting through this, for the profile bytes `icc` - /// this was made from. ICCBased CMYK colours embedding those same bytes separate as they + /// 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) @@ -131,7 +131,7 @@ impl CmykConversion for CmsConversion { } } -/// CIE L\*a\*b\* (D50) of an sRGB colour, relative colorimetric; `None` if craft-cms can't +/// CIE L\*a\*b\* (D50) of an sRGB color, relative colorimetric; `None` if craft-cms can't /// convert. pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { static T: OnceLock>> = OnceLock::new(); diff --git a/crates/separations/src/ink.rs b/crates/separations/src/ink.rs index 3f079243..b023b1cf 100644 --- a/crates/separations/src/ink.rs +++ b/crates/separations/src/ink.rs @@ -1,4 +1,4 @@ -//! The ink model: which inks a job prints with, and colours as tints of those inks. +//! 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}; @@ -21,7 +21,7 @@ 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` colour space gives this ink. + /// The colorant name a `Separation` or `DeviceN` color space gives this ink. pub fn name(self) -> &'static str { match self { Self::Cyan => "Cyan", @@ -36,13 +36,13 @@ impl Process { Self::ALL.into_iter().find(|p| p.name() == name) } - /// The ink's position in a CMYK colour. + /// 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 colour. +/// 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`. @@ -54,7 +54,7 @@ pub enum Alternate { /// A spot ink. #[derive(Clone, Debug, PartialEq)] pub struct Spot { - /// The colorant name, as a `Separation` colour space would name it, without PDF name + /// 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%. @@ -135,7 +135,7 @@ impl InkSet { /// 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 normalised. `None` for the + /// `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 { @@ -338,14 +338,14 @@ impl InkSet { } } -/// A colour as ink tints. +/// 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` colour, several a `DeviceN` one. + /// `Separation` color, several a `DeviceN` one. Tints(Vec<(InkId, f32)>), - /// The same tint on every plate: the registration colour (the `All` colorant). + /// The same tint on every plate: the registration color (the `All` colorant). All(f32), /// No ink on any plate (the `None` colorant). None, diff --git a/crates/separations/src/plates.rs b/crates/separations/src/plates.rs index 33b72fac..ea7ebd25 100644 --- a/crates/separations/src/plates.rs +++ b/crates/separations/src/plates.rs @@ -5,7 +5,7 @@ //! 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 colour converts to CMYK through the +//! 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 @@ -50,7 +50,7 @@ const MAX_PROFILE_BYTES: usize = 16 << 20; /// The longest output condition kept, in characters. const MAX_CONDITION: usize = 160; -/// The printing condition process colour converts to when the page has no usable output +/// The printing condition process color converts to when the page has no usable output /// intent. #[derive(Clone, Debug, Default)] pub enum Fallback { @@ -59,7 +59,7 @@ pub enum Fallback { Fogra39, /// The caller's CMYK ICC profile. Profile(Arc<[u8]>), - /// The ISO 32000-2 §10.4.2.4 device formulas, black generation and undercolour removal the + /// 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, } @@ -67,7 +67,7 @@ pub enum Fallback { /// How [`plates`] separates. #[derive(Clone, Debug)] pub struct Options { - /// Honour `OP`, `op` and `OPM` as a separating device does. Without it every paint knocks + /// 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. @@ -80,7 +80,7 @@ impl Default for Options { } } -/// How process colour that isn't CMYK already became CMYK. +/// 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 @@ -179,7 +179,7 @@ pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result separated.push(ink), @@ -207,7 +207,7 @@ fn render<'a>( settings: &InterpreterSettings, scale: f32, ) -> Result { - // The plate's gray film must keep its tints: no colour replacement. + // 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)?) } diff --git a/crates/separations/tests/plates.rs b/crates/separations/tests/plates.rs index 85c5737f..dbde4772 100644 --- a/crates/separations/tests/plates.rs +++ b/crates/separations/tests/plates.rs @@ -29,7 +29,7 @@ fn stream(dict: &str, data: &[u8]) -> Vec { out } -/// A spot colour whose alternate is a yellowish CMYK. +/// 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`. @@ -101,7 +101,7 @@ fn near(got: [u8; 4], expected: [u8; 4]) -> bool { } #[test] -fn process_colour_converts_through_the_output_intent_else_the_fallback() { +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)]); @@ -145,7 +145,7 @@ fn spot_inks_past_the_cap_are_listed_not_separated_and_bad_input_is_refused() { } #[test] -fn lab_colours_convert_through_the_profile_and_lab_white_is_paper() { +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(); diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 91d6bf4b..07048f41 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -13,7 +13,7 @@ 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-colour images) | +| #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` | @@ -26,19 +26,19 @@ re-sync brings these PdfCraft changes: | #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 colour space the decoder refuses is drawn by its component count | +| #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 licence. Code comments keep the pull request's patch numbers, except +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 colour to an output intent's CMYK. Only the vendored interpreter part is ported; PdfCraft's `separations.rs` and Output Preview UI are not | +| [#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 @@ -49,9 +49,9 @@ object where two objects overlap. The erase pass resolves the group's resources `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 colour management. `CmykSimulation::new` keeps +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 colours reach the conversion as CIE XYZ relative to the D50 PCS +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`. diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 99267ef4..bacab802 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "ed70bdb9618cf4d65032e977efe60b4d36f868fa4040db1012bfaf9ab4f5fb6e" + "sha256": "50a039fbf0d8122b31fe03c0a06e2d4bb2994f50fd04072f6637233bc2ff9eb6" }, { "path": "src/context.rs", diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 46dac18b..981ec019 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -785,7 +785,7 @@ impl Lab { ] } - /// PdfCraft patch (36): the colour `input` as XYZ relative to [`PCS_WHITE`], relative + /// PdfCraft patch (36): the color `input` as XYZ relative to [`PCS_WHITE`], relative /// colorimetric: the space's white point becomes the PCS white (Bradford-adapted). fn to_pcs(&self, input: &[f32], manual_scale: bool) -> [f32; 3] { let xyz = self.xyz(self.components(input, manual_scale)); @@ -1091,8 +1091,8 @@ impl ICCProfile { }))) } - /// PdfCraft patch (36): `input` (whole colours of this profile's components) as XYZ - /// relative to [`PCS_WHITE`], three values per colour in `xyz`, through the separation hub + /// PdfCraft patch (36): `input` (whole colors of this profile's components) as XYZ + /// relative to [`PCS_WHITE`], three values per color in `xyz`, through the separation hub /// (see [`Hub`]). L*a*b* components are scaled to the profile's encoding unless /// `manual_scale` says they already are (an Indexed palette's). fn to_pcs(&self, input: &[f32], manual_scale: bool, xyz: &mut [f32]) -> Option<()> { @@ -1125,9 +1125,9 @@ impl ICCProfile { }; } transform.transform(encoded, xyz).ok()?; - for colour in xyz.chunks_exact_mut(3) { - let pcs = HUB.to_pcs(&[colour[0], colour[1], colour[2]]); - colour.copy_from_slice(&pcs); + for color in xyz.chunks_exact_mut(3) { + let pcs = HUB.to_pcs(&[color[0], color[1], color[2]]); + color.copy_from_slice(&pcs); } Some(()) } @@ -1456,15 +1456,15 @@ struct SimulationRepr { } /// The conversion into a [`CmykSimulation`]'s CMYK, relative colorimetric. Each call converts -/// whole colours, at most 256 of them; CMYK components are `0.0..=1.0`, four per colour. A -/// method returns `None` when it can't convert, and the colour then separates by the device +/// whole colors, at most 256 of them; CMYK components are `0.0..=1.0`, four per color. A +/// method returns `None` when it can't convert, and the color then separates by the device /// formulas. pub trait CmykConversion: Send + Sync { - /// sRGB colours, gamma-encoded, three components in `0.0..=1.0` per colour, to CMYK. + /// sRGB colors, gamma-encoded, three components in `0.0..=1.0` per color, to CMYK. fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()>; - /// CIE XYZ colours relative to [`PCS_WHITE`] (the Y of white is 1), three components per - /// colour, to CMYK. The source's white has already been adapted to the PCS white. + /// CIE XYZ colors relative to [`PCS_WHITE`] (the Y of white is 1), three components per + /// color, to CMYK. The source's white has already been adapted to the PCS white. fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()>; /// CMYK bytes, four per pixel, to sRGB bytes, three per pixel, to show what the printing @@ -1487,7 +1487,7 @@ pub trait CmykConversion: Send + Sync { } /// The D50 white of the ICC profile connection space (ICC.1 §7.2.16), the white that -/// [`CmykConversion::from_pcs_xyz`] takes its colours relative to. +/// [`CmykConversion::from_pcs_xyz`] takes its colors relative to. pub const PCS_WHITE: [f32; 3] = [0.9642, 1.0, 0.8249]; impl Debug for CmykSimulation { @@ -1530,7 +1530,7 @@ impl CmykSimulation { } /// The printing condition of the CMYK ICC profile `profile`, converted to by `conversion`, - /// which the caller made from that profile. ICCBased CMYK colours in `profile` itself stay + /// which the caller made from that profile. ICCBased CMYK colors in `profile` itself stay /// as they are. pub fn with_conversion(profile: &[u8], conversion: impl CmykConversion + 'static) -> Self { Self(Arc::new(SimulationRepr { @@ -1569,7 +1569,7 @@ impl CmykSimulation { } } -/// PdfCraft patch (36): the conversion [`CmykSimulation::new`] makes with moxcms. XYZ colours +/// PdfCraft patch (36): the conversion [`CmykSimulation::new`] makes with moxcms. XYZ colors /// pass through the separation hub (see [`Hub`]). struct MoxcmsConversion { description: Option, @@ -1708,7 +1708,7 @@ fn transform_u8( static SRGB: LazyLock = LazyLock::new(ColorProfile::new_srgb); -/// PdfCraft patch (36): the separation hub, the RGB that ICCBased colours leave their profile +/// PdfCraft patch (36): the separation hub, the RGB that ICCBased colors leave their profile /// in, and that [`MoxcmsConversion`] takes PCS XYZ to its CMYK through: ROMM RGB (ProPhoto) /// primaries, D50 white, gamma 1.8. Its /// gamut holds practically every surface colour, so nothing printable clips on the way, and From 4585efee97b0120d7a50ae3717db49e97dd3991d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:39:36 +0000 Subject: [PATCH 11/11] prepress: rename craft-separations to craft-prepress --- .github/workflows/pdf.yml | 10 +++++----- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- crates/{separations => prepress}/Cargo.toml | 4 ++-- crates/{separations => prepress}/README.md | 12 +++++++----- crates/{separations => prepress}/src/cms.rs | 0 .../{separations => prepress}/src/composite.rs | 0 .../{separations => prepress}/src/coverage.rs | 0 crates/{separations => prepress}/src/ink.rs | 0 crates/{separations => prepress}/src/lib.rs | 2 +- crates/{separations => prepress}/src/plates.rs | 0 .../{separations => prepress}/tests/plates.rs | 4 ++-- 12 files changed, 27 insertions(+), 25 deletions(-) rename crates/{separations => prepress}/Cargo.toml (65%) rename crates/{separations => prepress}/README.md (96%) rename crates/{separations => prepress}/src/cms.rs (100%) rename crates/{separations => prepress}/src/composite.rs (100%) rename crates/{separations => prepress}/src/coverage.rs (100%) rename crates/{separations => prepress}/src/ink.rs (100%) rename crates/{separations => prepress}/src/lib.rs (87%) rename crates/{separations => prepress}/src/plates.rs (100%) rename crates/{separations => prepress}/tests/plates.rs (97%) diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 51ccf4f2..96b4ea7c 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -3,9 +3,9 @@ name: Shared PDF on: push: branches: [main] - paths: ["crates/pdf/**", "crates/separations/**", "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/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/separations/**", "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/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: @@ -46,11 +46,11 @@ jobs: - run: cargo clippy -p craft-pdf --all-targets --all-features --locked -- -D warnings - run: cargo test -p craft-pdf --all-features --locked - run: cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked - - name: Separations (plates through the vendored renderer) + - name: craft-prepress (plates through the vendored renderer) if: matrix.os == 'ubuntu-latest' run: | - cargo clippy -p craft-separations --all-targets --locked -- -D warnings - cargo test -p craft-separations --locked + cargo clippy -p craft-prepress --all-targets --locked -- -D warnings + cargo test -p craft-prepress --locked backends: name: PDF vendored contracts runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index ba814a83..3d133dbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,6 +634,15 @@ dependencies = [ "wasm-bindgen-test", ] +[[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" @@ -657,15 +666,6 @@ dependencies = [ "objc2-app-kit 0.3.2", ] -[[package]] -name = "craft-separations" -version = "0.1.0" -dependencies = [ - "craft-cms", - "craft-pdf", - "thiserror 2.0.21", -] - [[package]] name = "craft-settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2ba6ad5f..f0499394 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,9 @@ 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-separations = { path = "crates/separations" } craft-settings = { path = "crates/settings" } craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } diff --git a/crates/separations/Cargo.toml b/crates/prepress/Cargo.toml similarity index 65% rename from crates/separations/Cargo.toml rename to crates/prepress/Cargo.toml index 4cdec570..3e65e8b9 100644 --- a/crates/separations/Cargo.toml +++ b/crates/prepress/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "craft-separations" -description = "Ink model and per-ink plates rendered from a PDF, for the Craft apps' print output" +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 diff --git a/crates/separations/README.md b/crates/prepress/README.md similarity index 96% rename from crates/separations/README.md rename to crates/prepress/README.md index a90df300..3c0dcd6b 100644 --- a/crates/separations/README.md +++ b/crates/prepress/README.md @@ -1,6 +1,6 @@ -# craft-separations +# craft-prepress -Print separations for the Craft apps: +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 @@ -13,12 +13,14 @@ Print separations for the Craft apps: - `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_separations::{plates, InkId, Options, Process}; +use craft_prepress::{plates, InkId, Options, Process}; let p = plates(&pdf, 0, 150.0, &Options::default())?; for (id, ink) in p.inks.iter() { @@ -101,11 +103,11 @@ Overprint is already in the plates (the renderer decides per plate what overprin knocks out), so the preview shows it without rewriting any paint as a blend mode. ```rust -use craft_separations::{CmsConversion, InkId}; +use craft_prepress::{CmsConversion, InkId}; let cms = CmsConversion::fogra39(); let mut enabled = vec![true; p.inks.len()]; -enabled[InkId::process(craft_separations::Process::Black).index()] = false; // hide Black +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 diff --git a/crates/separations/src/cms.rs b/crates/prepress/src/cms.rs similarity index 100% rename from crates/separations/src/cms.rs rename to crates/prepress/src/cms.rs diff --git a/crates/separations/src/composite.rs b/crates/prepress/src/composite.rs similarity index 100% rename from crates/separations/src/composite.rs rename to crates/prepress/src/composite.rs diff --git a/crates/separations/src/coverage.rs b/crates/prepress/src/coverage.rs similarity index 100% rename from crates/separations/src/coverage.rs rename to crates/prepress/src/coverage.rs diff --git a/crates/separations/src/ink.rs b/crates/prepress/src/ink.rs similarity index 100% rename from crates/separations/src/ink.rs rename to crates/prepress/src/ink.rs diff --git a/crates/separations/src/lib.rs b/crates/prepress/src/lib.rs similarity index 87% rename from crates/separations/src/lib.rs rename to crates/prepress/src/lib.rs index a1676f96..3543f633 100644 --- a/crates/separations/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -1,6 +1,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -//! Print separations for the Craft apps: an ink model and ink manager shared by the writers, +//! 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. diff --git a/crates/separations/src/plates.rs b/crates/prepress/src/plates.rs similarity index 100% rename from crates/separations/src/plates.rs rename to crates/prepress/src/plates.rs diff --git a/crates/separations/tests/plates.rs b/crates/prepress/tests/plates.rs similarity index 97% rename from crates/separations/tests/plates.rs rename to crates/prepress/tests/plates.rs index dbde4772..2b27dadf 100644 --- a/crates/separations/tests/plates.rs +++ b/crates/prepress/tests/plates.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use craft_cms::{Intent, Profile, Transform, builtin}; -use craft_separations::{Alternate, CmsConversion, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; +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). @@ -92,7 +92,7 @@ fn red_in(profile: &[u8]) -> [u8; 4] { cmyk.map(|v| (v * 255.0).round() as u8) } -fn red_tints(p: &craft_separations::Plates) -> [u8; 4] { +fn red_tints(p: &craft_prepress::Plates) -> [u8; 4] { Process::ALL.map(|c| p.tint(InkId::process(c), 20, 20).unwrap()) }