Skip to content

fix: convert DeviceCMYK via process inks, not 1-(c+k)#861

Open
ajbufort wants to merge 5 commits into
yfedoseev:mainfrom
ajbufort:fix/cmyk-process-inks
Open

fix: convert DeviceCMYK via process inks, not 1-(c+k)#861
ajbufort wants to merge 5 commits into
yfedoseev:mainfrom
ajbufort:fix/cmyk-process-inks

Conversation

@ajbufort

@ajbufort ajbufort commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Update (2026-07-19): unified across all three paths. Following the review, this PR now resolves the 3-way split — the process-ink model is applied to extractors/images::cmyk_pixel_to_rgb (raster images) and rendering/page_renderer (display + the RGB→CMYK overprint sidecar, via a new process-ink separation color::rgb_to_cmyk), in addition to the extraction/operator sites below. So an identical CMYK value now resolves to the same RGB everywhere. The "does not touch images.rs" note in the original description below is superseded. See the comment for the separation approach and the one behavioural change (out-of-gamut gamut compression under overprint). Full gate green.


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:

ink 1-(c+k) actual process ink
100% C #00FFFF #00ADEF
100% M #FF00FF #EC008C
100% Y #FFFF00 #FFF200
100% K #000000 #231F20

The 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 in document.rs) and extractors/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 / f fills), 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_rgb documents 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:

  1. Extend it to images.rs so 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.
  2. Keep it to text/operators as here, and I will open a follow-up for images.
  3. Gate it behind a feature/flag with §10.3.5 as the default, if you would rather the spec-literal path stay the default.

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 current main (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.
  • The 6 existing tests that encoded the old values are updated to the process inks.

Worth noting: on current main this also picks up the cs/scn DeviceCMYK and Separation-alternate paths in text.rs that call the same local helper, so the fix reaches further than the four operator sites.

Checklist

  • Code compiles without warnings
  • All tests pass (cargo test)
  • Code formatted (cargo fmt)
  • Clippy passes (cargo clippy -- -D warnings)
  • New code includes tests
  • Documentation updated (doc comment + example on the new public fn)
  • Commit messages follow conventions; DCO signed off
  • PR description explains changes

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>
@ajbufort
ajbufort requested a review from yfedoseev as a code owner July 14, 2026 14:00

@yfedoseev yfedoseev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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>
@ajbufort

Copy link
Copy Markdown
Contributor Author

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 impl Default for TextExtractor and actively misdescribed it. Fixed and pushed.

1. Blocker — orphaned doc comment: fixed

Removed with the function it documented. cargo test --features rendering (8,855 tests), cargo clippy --features rendering -- -D warnings and cargo fmt are all green.

2. The scope gap — you are right, and it goes deeper than the diff suggests

Your e2e is correct: the rendered output is unchanged, a 0 0 0 1 k fill still renders pure black, and after this merges the crate has three CMYK→RGB conversions with only the extraction ones unified. I should have caught that before submitting.

I took your ideal option first — extend color::cmyk_to_rgb to the render and image paths so there is exactly one conversion. It does not work as a drop-in, and the reason is worth having on record.

The renderer's RGB→CMYK inverse is mathematically paired with the additive clamp. page_renderer.rs::resolve_rgb_paint_to_cmyk maps an RGB paint to CMYK with the §10.3.5 inverse (C = 1-R, K = 0). That is the exact inverse of 1 - min(1, C+K), so today RGB → CMYK → RGB is an identity. Replace only the forward half with process inks and the identity breaks:

  • adversarial_rgb_overprint_paint_skips_mirror_no_panic — a pure-RGB paint (0 1 0 rg, no CMYK anywhere in the content stream) shifts from (127, 255, 127) to (127, 209, 143). Green (0,255,0) round-trips to process green #00A650 and comes back changed.

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. color::Transform::convert_cmyk_pixel falls back to extractors::images::cmyk_pixel_to_rgb, so changing images while the renderer stays on the additive clamp makes the ICC-fallback arm and the resolver arm disagree — which fails cmyk_to_rgb_via_intent_falls_back_when_profile_has_no_cmm. That is precisely the HONEST_GAP your own comment in that test predicted:

"this byte-exact agreement depends on crate::color::Transform::convert_cmyk_pixel matching crate::extractors::images::cmyk_pixel_to_rgb on the §10.3.5 path. If those two diverge in the future the helper here could disagree…"

So extraction, images, and the renderer all meet at convert_cmyk_pixel, and the renderer is additionally anchored by that inverse. No subset of the three can move on its own. A correct unification has to replace resolve_rgb_paint_to_cmyk with a separation consistent with the process-ink model — real colour engineering (a proper RGB→CMYK separation with black generation), not a swap. Doing that inside a review cycle on a text-extraction PR would be exactly the kind of scope creep you were right to push back on with #853.

What I propose

Take your second option: scope this PR to extraction, which is what it now is (document.rs + text.rs + the shared color::cmyk_to_rgb). It is self-consistent, fully tested, and strictly better than the status quo for every text consumer.

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: resolution/color.rs's own comment already says "a follow-up will collapse the two callers onto a single shared helper as part of the renderer-migration work" — so the unification you want is already on your roadmap; it just needs the separation to land with it.

ajbufort added a commit to ajbufort/pdf_oxide that referenced this pull request Jul 14, 2026
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>
@ajbufort
ajbufort requested a review from yfedoseev July 15, 2026 18:14
@ajbufort

Copy link
Copy Markdown
Contributor Author

The blocker is resolved - the orphaned doc comment was removed in f67b79c5 (the impl Default for TextExtractor now carries no stray CMYK doc block; grep "R = 1" in text.rs is empty).

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: page_renderer::resolve_rgb_paint_to_cmyk uses the §10.3.5 additive inverse, so RGB->CMYK->RGB is an identity today; swapping only the forward half shifts pure-RGB paint under overprint (e.g. green (0,255,0) round-trips to (0,166,80)), and color::Transform::convert_cmyk_pixel still falls back to images::cmyk_pixel_to_rgb. A correct renderer unification needs a process-ink-consistent RGB->CMYK separation (black generation), which is real colour engineering - happy to take it as a separate PR. Ready for re-review.

ajbufort added a commit to ajbufort/pdf_oxide that referenced this pull request Jul 16, 2026
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>
ajbufort added a commit to ajbufort/pdf_oxide that referenced this pull request Jul 17, 2026
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>
@yfedoseev

Copy link
Copy Markdown
Owner

Thanks — the code here is solid: the prior blocker (the orphaned impl Default for TextExtractor doc comment) is fixed, the tetralinear interpolation over the 16 measured ink corners is clean, and the SWOP-style corners match poppler/Acrobat. The routing through the shared color::cmyk_to_rgb is a nice DRY win.

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 (page_renderer::resolve_rgb_paint_to_cmyk) and the image pixel path (images.rs::cmyk_pixel_to_rgb) still use the additive 1 − min(1, C+K). An identical CMYK value would then resolve to different RGB depending on whether it's a vector fill, a raster image, or extracted text — I'd rather not ship that to main even temporarily.

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.

ajbufort added a commit to ajbufort/pdf_oxide that referenced this pull request Jul 19, 2026
…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>
ajbufort and others added 2 commits July 19, 2026 09:19
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)
@ajbufort

Copy link
Copy Markdown
Contributor Author

Expanded this to the single unified PR you asked for — all three DeviceCMYK conversion paths now resolve through the process-ink color::cmyk_to_rgb, so one CMYK value resolves the same everywhere:

  • Raster imagesextractors/images::cmyk_pixel_to_rgb (the chokepoint for raw-CMYK, CMYK-JPEG via decode_cmyk_jpeg_to_rgb, and Indexed-CMYK decode) now routes through color::cmyk_to_rgb. That's option 1 from my list.
  • Renderer displayrendering/page_renderer::cmyk_to_rgb now delegates to it.
  • Renderer RGB→CMYK sidecar inverseresolve_rgb_paint_to_cmyk now uses a new process-ink-consistent separation, color::rgb_to_cmyk: a bounded, per-paint Newton inversion of the K=0 trilinear forward (finite-difference Jacobian). It starts from — and provably never returns worse than — the additive complement (1-R, 1-G, 1-B, 0), so it can only tighten the round-trip.

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 cmyk_to_rgb unit tests are updated to the process-ink values (100% K = #231F20, 100% cyan = #00ADEF). Full gate is green (cargo test, fmt, clippy -D warnings).

One housekeeping note: the branch is behind main at the moment — happy to rebase it, or leave it for your squash-merge, whichever you prefer.

@yfedoseev

Copy link
Copy Markdown
Owner

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 rgb_to_cmyk separation for the overprint sidecar is handled cleanly — forward/inverse move together, floored at the additive complement). And the defect is real: 0 0 0 1 k should be the K-ink #231F20, not #000000.

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 document.rs/text.rs, extractors/images.rs::cmyk_pixel_to_rgb, and page_renderer.rs::cmyk_to_rgb (line 9244). But the path that actually paints DeviceCMYK vector fills and text to the page is run_pipeline_for_logicalresolution::color::cmyk_to_rgb_via_intent, whose no-OutputIntent fallback is src/rendering/resolution/color.rs::cmyk_to_rgb (line 650) — still 1 − min(1, C+K), untouched by this PR (the diff touches no file under src/rendering/resolution/). Worse, pipeline_resolve_* overrides the inline gs.fill_color_rgb you set, so the additive pipeline value wins for composited fills/text.

Empirical proof — I rendered 10 DeviceCMYK PDFs, main vs this branch:

5 IDENTICAL (render unchanged): issue-1054-example, 2201.00037, arxiv_2510.25673v1, IA_00-rec-redakcije …
5 DIFFER: the CMYK-*image* PDFs (the images.rs path you DID convert)

The DeviceCMYK vector/text pages render byte-identical to main — the process-ink conversion never reaches them. That's the exact e2e gap this PR was meant to close.

Two more things to fix:

  1. Likely test break under the icc feature. Your images.rs change is reached by cmyk_to_rgb_via_intent's no-CMM ICC fallback (color/mod.rs:270), so cmyk_to_rgb_via_intent_falls_back_when_profile_has_no_cmm (resolution/color.rs:1183, gated on icc-qcms/icc-lcms2) — which asserts the additive CMYK(0.25,0,0,0) → (0.75,1,1) — should now fail (process-ink ≈ 0.92). Please re-run the gate with cargo test --features icc-qcms,rendering; a green run without those features isn't conclusive here.
  2. New OutputIntent-dependent inconsistency: with a declared-but-unusable OutputIntent the composite path returns process-ink (via the images fallback), but with no OutputIntent it returns additive (via resolution/color.rs:650). DeviceCMYK colour shouldn't depend on whether a broken OutputIntent is present.

To land it: convert resolution/color.rs::cmyk_to_rgb (and the no-intent fallback in cmyk_to_rgb_via_intent) to the process-ink color::cmyk_to_rgb so the composite path agrees; update the resolves_device_cmyk_via_additive_clamp and the icc-fallback unit tests to the new expected values; and add a render-level integration test that rasterizes a DeviceCMYK vector-swatch + k-text page and pins the process-ink output (that's what would have caught this). Happy to re-review once the composite render is on process inks.

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>
@ajbufort

Copy link
Copy Markdown
Contributor Author

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 k-text is the resolution pipeline, not page_renderer::cmyk_to_rgb. I'd converted the wrong cmyk_to_rgb.

Fixed the live composite path in the latest push:

  • src/rendering/resolution/color.rs::cmyk_to_rgb (the no-OutputIntent fallback of cmyk_to_rgb_via_intent, reached by run_pipeline_for_logical) now delegates to crate::color::cmyk_to_rgb. That was the third additive copy. With it converted, DeviceCMYK vector/text composites on process inks, 0 0 0 1 k renders #231F20, and the OutputIntent-dependent inconsistency you flagged is gone: no-OutputIntent and declared-but-unusable-OutputIntent now both resolve through the same process-ink conversion.

  • Unit tests: resolves_device_cmyk_via_additive_clamp -> resolves_device_cmyk_via_process_inks (now asserts #00ADEF via the real composite projection), and the icc-gated cmyk_to_rgb_via_intent_falls_back_when_profile_has_no_cmm is updated to the process-ink value it now shares with the no-OutputIntent arm.

  • Render-level integration test (the pin that would have caught this): tests/test_render_cmyk_process_inks.rs rasterises a DeviceCMYK vector-swatch + k-text page with no OutputIntent and pins #00ADEF cyan, #231F20 K, and that no pixel on the page is pure (0,0,0).

  • Re-baselined the composite byte-exact pins across the wave1-5 pipeline QA, the OutputIntent-fallback suite, and the transparency-flattening suites. Every changed expectation is the process-ink colour of the CMYK involved (verified against the converter, not pasted from output). The fallback probes now derive their expected bytes from color::cmyk_to_rgb directly so they can't silently drift back to additive.

One honest behavioural note for the record: on the composite path, an out-of-gamut sRGB paint under /OP now gamut-compresses through the paired rgb_to_cmyk/cmyk_to_rgb round-trip (green 0 1 0 rg at ca 0.5 over white: (127,255,127) -> (127,209,143)). That's the same "smaller process gamut" consequence you and I discussed on the earlier round; I renamed the adversarial probe to adversarial_rgb_overprint_paint_gamut_compresses_no_panic and documented it inline rather than hide it. In-gamut paints are unaffected.

Gate: cargo test --features icc-qcms,rendering -> 8965 pass / 0 fail (ran with the icc features per your note, since a run without them isn't conclusive here); cargo fmt --check and cargo clippy --all-targets --features icc-qcms,rendering -- -D warnings both clean. Ready for re-review whenever you have a moment, and thanks again for the thorough e2e - it saved a real gap from shipping.

@yfedoseev yfedoseev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via run_pipeline_for_logical — the exact path that was still additive and overriding the inline fix)
  • cmyk_to_rgb_via_intent no-OutputIntent branch, page_renderer.rs, images.rs (raw/JPEG/Indexed-CMYK), document.rs SetFill/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#231F20 instead of pure black, 1 0 0 0 k#00ADEF instead 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_inks passes on this PR; reverting only resolution/color.rs makes 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants