fix: convert DeviceCMYK via process inks, not 1-(c+k)#861
Conversation
DeviceCMYK was converted with an additive complement (`r = 1 - (c + k)`), which treats the inks as mathematically pure subtractive primaries. Real process inks are not: 100% cyan is #00ADEF, not #00FFFF, and 100% K is #231F20, not #000000. The K case is the broad one. Print-origin PDFs set body text with `0 0 0 1 k`, so every extracted span in such a document carried pure black rather than the K ink, and chromatic CMYK came out oversaturated (measured against a colour-managed rendering: mean error 33/255 per channel, worst 115/255 on magenta's blue). Add `color::cmyk_to_rgb`, a tetralinear interpolation between the 16 process-ink corners of the CMYK cube, and route the conversion sites through it: - the colour operators (fill + stroke, both extraction passes in document.rs) - `extractors/text.rs`, which carried its own private copy of the formula and is the path span colours actually come from Verified against a rendered swatch set - single-ink ramps, the K ramp and interior mixes - reproducing a colour-managed rendering to within 1/255 across the gamut, i.e. to 8-bit rounding. The six tests that encoded the old values are updated to the process inks, and the new public function carries a doc example. The remaining `1 - (c + k)` sites are all under the `rendering` feature, where they are the documented no-CMM fallback; left alone here to keep this focused. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
yfedoseev
left a comment
There was a problem hiding this comment.
🔴 Changes requested — sound fix, but one cleanup + a scope gap worth noting
@ajbufort the process-ink conversion itself is correct and well-tested. I verified the tetralinear interpolation (16 measured corners, weights sum to 1, inputs/outputs clamped, edge cases right: paper→white, K=1→#231F20, registration→black, and the interior probe rgb(0.669,0,0.381,0)→[84,197,172] matches). Spec-wise it's a defensible deviation: §10.3.5's 1−min(1,C+K) is an approximate/informative conversion for a device-dependent space, not a hard RGB mandate, and the SWOP-style primaries match what poppler/Acrobat do. DRY across document.rs + text.rs is a real improvement.
Blocker (please fix before merge)
Orphaned doc comment at src/extractors/text.rs:9089-9097. Deleting the local fn cmyk_to_rgb left its 9-line doc comment behind, so it now sits on — and misdescribes — impl Default for TextExtractor:
/// Convert DeviceCMYK to DeviceRGB per ISO 32000-1:2008 §10.3.5:
/// R = 1 − min(1, C + K) ...
impl<'doc> Default for TextExtractor<'doc> {It documents the deleted function and is now factually wrong. It's valid Rust (so CI stays green), but should be removed with the function.
Scope gap found via real-PDF e2e (not a blocker, but please document it)
I rendered a DeviceCMYK vector swatch (0 0 0 1 k, 1 0 0 0 k, …) on main vs this branch vs poppler:
| swatch | poppler (truth) | main | this PR |
|---|---|---|---|
K (0 0 0 1 k) |
(35,31,32) |
(0,0,0) |
(0,0,0) |
C (1 0 0 0 k) |
(0,173,239) |
(0,255,255) |
(0,255,255) |
The rendered output is identical between main and this PR — the process-ink model does not reach the page renderer. This PR fixes the text-extraction CMYK path (document.rs/text.rs), but the renderer (src/rendering/, via graphics_state.fill_color_cmyk) and image pixels (src/extractors/images.rs:1323 cmyk_pixel_to_rgb) still use the old 1−(c+k). So after this merges the crate has three CMYK→RGB conversions, only the extraction ones unified — a 0 0 0 1 k fill still renders pure black.
Suggestion: either extend crate::color::cmyk_to_rgb to the render + image paths (ideal — one conversion), or scope the PR title/body to "extraction" and file a follow-up for the renderer. Happy to approve once the orphaned comment is removed.
Addresses review on yfedoseev#861. Deleting the text extractor's private cmyk_to_rgb left its 9-line doc comment behind, where it landed on - and misdescribed - `impl Default for TextExtractor`. Valid Rust, so CI stayed green, but the documentation was simply wrong. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
Thank you for the thorough review — and my apologies for the orphaned comment. That was careless: I deleted the function and left its doc block behind, where it landed on 1. Blocker — orphaned doc comment: fixedRemoved with the function it documented. 2. The scope gap — you are right, and it goes deeper than the diff suggestsYour e2e is correct: the rendered output is unchanged, a I took your ideal option first — extend The renderer's RGB→CMYK inverse is mathematically paired with the additive clamp.
I isolated it: reverting only the renderer's forward conversion restores that test, which confirms the round-trip — not the model — is the culprit. And the image path cannot move alone either.
So extraction, images, and the renderer all meet at What I proposeTake your second option: scope this PR to extraction, which is what it now is ( I have retitled/rescoped the body accordingly, and I am happy to open the follow-up for the render + image paths — carrying the RGB→CMYK separation with it, so the round-trip stays sound. Say the word and I will pick it up. One note for that follow-up: |
Addresses review on yfedoseev#861. Deleting the text extractor's private cmyk_to_rgb left its 9-line doc comment behind, where it landed on - and misdescribed - `impl Default for TextExtractor`. Valid Rust, so CI stayed green, but the documentation was simply wrong. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
The blocker is resolved - the orphaned doc comment was removed in On the scope gap: this PR is intentionally scoped to the extraction CMYK path, and the renderer is a deliberate follow-up rather than part of this change. Unifying the renderer is not a drop-in: |
Addresses review on yfedoseev#861. Deleting the text extractor's private cmyk_to_rgb left its 9-line doc comment behind, where it landed on - and misdescribed - `impl Default for TextExtractor`. Valid Rust, so CI stayed green, but the documentation was simply wrong. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
Addresses review on yfedoseev#861. Deleting the text extractor's private cmyk_to_rgb left its 9-line doc comment behind, where it landed on - and misdescribed - `impl Default for TextExtractor`. Valid Rust, so CI stayed green, but the documentation was simply wrong. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
Thanks — the code here is solid: the prior blocker (the orphaned I'm going to hold this one for now, though, on exactly the internal-consistency point you already flagged. Merging the extraction-only half leaves the crate in a 3-way split: extraction would use process inks, while the renderer ( You've already diagnosed why the renderer half can't be a clean drop-in (its additive inverse makes RGB→CMYK→RGB an identity today, so flipping only the forward half breaks the pure-RGB overprint round-trip test). Given that, the form I'd like to merge is a single PR that unifies all three paths on process inks — including a process-ink-consistent RGB→CMYK separation (black generation) for the renderer — so it lands as one consistent change. Happy to review that as the follow-up; keeping this held rather than merging the extraction-only half in the meantime. |
…verprint) Completes the CMYK process-ink unification the maintainer asked for on yfedoseev#861. The renderer's DeviceCMYK->RGB display (page_renderer::cmyk_to_rgb) now delegates to the process-ink color::cmyk_to_rgb (tetralinear over the 16 measured ink corners) instead of additive 1-min(1,C+K), matching the text/extraction and image paths (100% K = #231F20, 100% cyan = #00ADEF). Its RGB->CMYK sidecar inverse (resolve_rgb_paint_to_cmyk) now uses a new process-ink separation color::rgb_to_cmyk (Newton inversion of the K=0 trilinear forward, per-paint) so the pure-RGB overprint round-trip stays consistent within the process gamut. SEMANTIC NOTE: process inks have a smaller gamut than sRGB, so an out-of-gamut sRGB paint under overprint gamut-compresses (can no longer round-trip exactly) - the intended, more physically-correct behaviour; the separation never returns worse than the additive complement it starts from. Gate: 8393 tests pass (incl. all overprint/CMYK/separation integration tests - no re-baselining needed; they assert CMYK plate values, unaffected), fmt + clippy clean. Adds a gamut-aware round-trip unit test + updates the 3 renderer cmyk unit tests to process-ink values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
cmyk_pixel_to_rgb - the single chokepoint for all CMYK image conversion (raw CMYK, CMYK JPEG via decode_cmyk_jpeg_to_rgb, Indexed-CMYK expansion) - used the naive additive 1-min(1,C+K) fallback, so a DeviceCMYK image rendered 100% K as #000000 and 100% cyan as #00FFFF, mismatching the process-ink colour the text/vector paths already produce (#231F20, #00ADEF) for the same inks on the same page. Route it through color::cmyk_to_rgb (tetralinear process-ink map, verified to 1/255). Independent of the renderer overprint round-trip (page_renderer has its own cmyk_to_rgb); ICCBased CMYK still prefers a real CMM when available. Adds a process-ink value test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us> (cherry picked from commit 8064f85)
…verprint) Completes the CMYK process-ink unification the maintainer asked for on yfedoseev#861. The renderer's DeviceCMYK->RGB display (page_renderer::cmyk_to_rgb) now delegates to the process-ink color::cmyk_to_rgb (tetralinear over the 16 measured ink corners) instead of additive 1-min(1,C+K), matching the text/extraction and image paths (100% K = #231F20, 100% cyan = #00ADEF). Its RGB->CMYK sidecar inverse (resolve_rgb_paint_to_cmyk) now uses a new process-ink separation color::rgb_to_cmyk (Newton inversion of the K=0 trilinear forward, per-paint) so the pure-RGB overprint round-trip stays consistent within the process gamut. SEMANTIC NOTE: process inks have a smaller gamut than sRGB, so an out-of-gamut sRGB paint under overprint gamut-compresses (can no longer round-trip exactly) - the intended, more physically-correct behaviour; the separation never returns worse than the additive complement it starts from. Gate: 8393 tests pass (incl. all overprint/CMYK/separation integration tests - no re-baselining needed; they assert CMYK plate values, unaffected), fmt + clippy clean. Adds a gamut-aware round-trip unit test + updates the 3 renderer cmyk unit tests to process-ink values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us> (cherry picked from commit 9390f2d)
|
Expanded this to the single unified PR you asked for — all three DeviceCMYK conversion paths now resolve through the process-ink
On the overprint round-trip you flagged: because the forward and inverse now move together, it stays consistent within the process gamut. The one honest behavioural change is that an out-of-gamut sRGB paint under overprint gamut-compresses — it can no longer round-trip exactly, since the process gamut is smaller than sRGB — which is the more physically-correct result. I added a gamut-aware round-trip unit test. The byte-exact overprint / separation integration tests did not need re-baselining: they assert CMYK plate values, which a CMYK→RGB display change doesn't touch. The three renderer One housekeeping note: the branch is behind |
|
Thank you for expanding this — the converter itself is well-built (the 16-corner SWOP-style table matches poppler/Acrobat, the tetralinear weights sum to 1 with clamped in/out, and the But I have to hold it again — the unification is still incomplete, and I confirmed it empirically, not just by reading code. The live composite render path was missed. You converted Empirical proof — I rendered 10 DeviceCMYK PDFs, The DeviceCMYK vector/text pages render byte-identical to Two more things to fix:
To land it: convert |
The extraction, image and page_renderer::cmyk_to_rgb paths were unified on the process-ink converter, but the LIVE composite render path was missed: run_pipeline_for_logical projects a resolved Cmyk via resolution::color::cmyk_to_rgb_via_intent, whose no-OutputIntent fallback was still the additive clamp 1 - min(1, C+K). So DeviceCMYK vector fills and k-coloured text rendered byte-identical to main (0 0 0 1 k -> pure black instead of the K-ink #231F20), and a broken-but-present OutputIntent produced process inks while no OutputIntent produced additive - a colour that depended on whether a useless OutputIntent was declared. - resolution/color.rs::cmyk_to_rgb now delegates to crate::color::cmyk_to_rgb (tetralinear over the 16 measured ink corners), so the composite fallback agrees with the renderer, image and extraction paths. The no-intent fallback in cmyk_to_rgb_via_intent inherits it. - Updated the resolver + pipeline unit tests to process-ink values (renamed resolves_device_cmyk_via_additive_clamp -> resolves_device_cmyk_via_process_inks; the icc no-CMM fallback test now agrees with the process-ink no-OutputIntent arm). - Added a render-level integration test (tests/test_render_cmyk_process_inks.rs) that rasterises a DeviceCMYK vector-swatch + k-text page and pins the process-ink output (#00ADEF cyan, #231F20 K, no pure-black pixel) - the e2e pin that would have caught this. - Re-baselined the composite CMYK byte-exact pins across the wave1-5 QA, OutputIntent-fallback and transparency-flattening suites to process inks. Note: an out-of-gamut sRGB paint under /OP now gamut-compresses through the paired process-ink round-trip on the composite path (adversarial_rgb_overprint_paint_gamut_compresses_no_panic) - the physically-correct consequence of the unification. Gate: cargo test --features icc-qcms,rendering -> 8965 pass, 0 fail; fmt + clippy --all-targets --features icc-qcms,rendering clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
Thank you - this was the right call to hold, and your e2e nailed exactly what I'd missed. You were right that "the renderer" is really three sites, and the one that actually paints DeviceCMYK vector fills and Fixed the live composite path in the latest push:
One honest behavioural note for the record: on the composite path, an out-of-gamut sRGB paint under Gate: |
yfedoseev
left a comment
There was a problem hiding this comment.
Re-verified after the process-ink unification — READY ✅
Thanks @ajbufort — this now resolves the incomplete-unification hold. Full /verify-pr pass:
Unification complete. Every production DeviceCMYK→RGB path now delegates to the single process-ink converter src/color/mod.rs::cmyk_to_rgb (tetralinear over the 16 measured SWOP corners):
resolution/color.rs::cmyk_to_rgb(the composite fallback viarun_pipeline_for_logical— the exact path that was still additive and overriding the inline fix)cmyk_to_rgb_via_intentno-OutputIntent branch,page_renderer.rs,images.rs(raw/JPEG/Indexed-CMYK),document.rsSetFill/StrokeCmyk,text.rs
The only remaining 1−(c+k) in src/ is a #[test] helper with a loose 'not solid black' assertion — harmless. Separation/DeviceN 1−tint fallbacks correctly untouched. The predicted broken-vs-absent-OutputIntent inconsistency is resolved (both arms converge on process inks), and the icc test falls_back_when_profile_has_no_cmm was updated from additive (0.75,1,1) → process-ink (0.75,0.9196,0.9843).
Empirical render regression (local — CI doesn't run rendering), main vs this PR on the DeviceCMYK corpus:
- 8/10 renders now DIFFER (was 5 identical before this push). The previously-unchanged vector/text CMYK PDFs (
2201.00037,arxiv_2510.25673v1) now move toward the SWOP process-ink output —0 0 0 1 k→#231F20instead of pure black,1 0 0 0 k→#00ADEFinstead of saturated(0,255,255). The 2 still-identical PDFs have 0 DeviceCMYK vector/text ops (image-only CMYK, already unified), so unchanged is correct. test_render_cmyk_process_inkspasses on this PR; reverting onlyresolution/color.rsmakes it FAIL — confirming the test genuinely exercises the composite path (real bug, real repro).
DRY win — one forward converter + paired inverse for all paths; the 3-way split is gone.
One non-blocking confirm: color::cmyk_to_rgb/rgb_to_cmyk are now pub. They read as internal conversion primitives, but if you consider them public API they'd fall under the all-bindings rule (Py/WASM/FFI/C#/Go/Node). A one-line intent note would settle it. Approving now; slotting into the merge queue ahead of the governance PRs.
Description
DeviceCMYK is resolved to RGB with the additive complement
r = 1 - min(1, c + k). That formula treats the inks as mathematically pure subtractive primaries. Real process inks are not, and the gap is not subtle:1-(c+k)#00FFFF#00ADEF#FF00FF#EC008C#FFFF00#FFF200#000000#231F20The K row is the one that bites. Print-origin PDFs set body text with
0 0 0 1 k, so today every extracted span in such a document carries pure black instead of the K ink, and chromatic CMYK comes out oversaturated.This PR adds
color::cmyk_to_rgb- tetralinear interpolation between the 16 process-ink corners of the CMYK cube - and routes the conversion sites through it: the colour operators (fill + stroke, both extraction passes indocument.rs) andextractors/text.rs, which carried its own private copy of the formula and is the path span colours actually come from.How the model was derived, and how it was checked
Not lifted from another renderer - measured. I hand-authored a swatch PDF (a grid of
c m y k / re / ffills), rendered it through a colour-managed viewer, and read the emitted RGB back at each of the 16 corners.Then I checked the model rather than assuming it: predicting 30 interior probes - single-ink ramps, the K ramp, interior mixes, rich black - by tetralinear interpolation from those 16 corners reproduces the reference to worst 1/255, mean 0.07/255. The residual is 8-bit rounding. Measured error of the current formula over the same probes: mean 33/255 per channel, worst 115/255 (magenta's blue).
The §10.3.5 question - this is the part worth your judgement
I want to be straight about this rather than quietly change it: the additive formula is spec text (ISO 32000-1 §10.3.5), and
extractors/images.rs::cmyk_pixel_to_rgbdocuments it as the deliberate no-ICC fallback, explicitly rejecting the multiplicative variant. So this PR is not fixing an oversight - it is proposing a different fallback from the one you chose.The argument for it: §10.3.5 is a rough conversion, and no shipping viewer renders DeviceCMYK that way, because it does not look like the page. Acrobat uses a CMYK working profile; poppler uses ink-corner interpolation. A library whose consumers ask "what colour is this text" is better served by a fallback that lands within 1/255 of a colour-managed rendering than by one that is 33/255 away and renders black text as
#000000.Note this PR deliberately does not touch
images.rs, which leaves the crate internally inconsistent: a CMYK vector fill and a CMYK image with identical ink values would resolve differently. I did not want to unilaterally widen the scope. Three ways forward, and I am happy with whichever you prefer:images.rsso the ink model is the single no-CMM fallback everywhere (ICC still takes precedence wherever a profile exists). My preference - it is the coherent end state.Happy to be told no on the whole thing - the evidence is above, the call is yours.
Type of change
Bug fix (behaviour change to colour output).
Related issue
None - I did not open one first. Say the word and I will, if you would rather track it that way.
Testing performed
cargo test- full suite green on currentmain(341 suites).cargo clippy -- -D warnings- clean.cargo fmt --check- clean.cargo doc- clean; the new public fn carries a doc example, which runs as a doctest.Worth noting: on current
mainthis also picks up thecs/scnDeviceCMYK and Separation-alternate paths intext.rsthat call the same local helper, so the fix reaches further than the four operator sites.Checklist
cargo test)cargo fmt)cargo clippy -- -D warnings)