Skip to content

BUG: Keep the Adobe CMYK inversion when an explicit /Decode is present - #3943

Merged
stefan6419846 merged 3 commits into
py-pdf:mainfrom
RavSinghChandan:fix-cmyk-decode-inversion
Aug 11, 2026
Merged

stefan6419846 merged 3 commits into
py-pdf:mainfrom
RavSinghChandan:fix-cmyk-decode-inversion

Conversation

@RavSinghChandan

Copy link
Copy Markdown
Contributor

Fixes #2931.

Adobe writes CMYK JPEGs with inverted component values, and _apply_decode
compensates with a [1 0] remap. That branch is only reached when the image has
no /Decode array, so an image that carries an explicit /Decode skips the
inversion and comes out inverted.

The example PDF on the issue has /Decode [0 1 0 1 0 1 0 1] — the identity — so
nothing is remapped at all and the extracted image is inverted:

>>> reader.pages[0].images[0].image.convert("RGB").getpixel((0, 0))
(19, 17, 18)        # near-black; the background is light grey

/Decode is expressed in terms of the true component values, not the stored
inverted ones, so it has to be composed with the inversion rather than replace
it. Substituting s -> 1-s into dmin + s*(dmax-dmin) gives dmax + s*(dmin-dmax),
which is just the pair swapped, so the fix is to swap each [min max] pair for
CMYK + DCTDecode.

With that, the same pixel reads (112, 128, 118) and the image matches the
original.

Added a regression test using the PDF from the issue; it fails on main with the
inverted pixel value.

Adobe writes CMYK JPEGs with inverted component values, which _apply_decode
compensates for with a [1 0] remap. That branch was only reached when the
image had no /Decode array, so an image carrying an explicit identity /Decode
skipped the inversion entirely and was extracted with inverted colours.

/Decode is expressed in terms of the true component values, so it has to be
composed with the inversion rather than replace it. Substituting the inversion
into the mapping works out to swapping each [min max] pair.
@codecov

codecov Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.89%. Comparing base (aa2f37d) to head (e4ec79e).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3943   +/-   ##
=======================================
  Coverage   97.88%   97.89%           
=======================================
  Files          57       57           
  Lines       10829    10871   +42     
  Branches     2028     2033    +5     
=======================================
+ Hits        10600    10642   +42     
  Misses        127      127           
  Partials      102      102           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@stefan6419846 stefan6419846 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR. I have left some remarks where the current code does not make sense for me or can be improved.

Comment thread pypdf/generic/_image_xobject.py Resolved
Comment thread pypdf/generic/_image_xobject.py Resolved
Comment thread pypdf/generic/_image_xobject.py Resolved
Comment thread pypdf/generic/_image_xobject.py Resolved
Comment thread tests/test_images.py Resolved
- drop the even-length guard: an odd /Decode array is already caught further
  down and dropped with a warning, so guarding here only skipped the inversion
  silently
- iterate to len(decode) - 1 so an odd array cannot read past its end
- reword the comment: /Decode is not limited to two entries, and explain the
  substitution that makes the swap correct
- replace the em dash with a plain hyphen
- assert exact raw CMYK samples instead of an RGB range; the raw values are
  produced by pypdf rather than Pillow's colour management, so they are stable
  across dependency versions
@RavSinghChandan

Copy link
Copy Markdown
Contributor Author

Thanks — all five addressed.

On the even-length guard: you're right that it made no sense. An odd /Decode
array is already caught further down and dropped with a warning, so guarding
here only meant the inversion was skipped silently while the malformed array
still got reported. Removed, and the loop now stops at len(decode) - 1 so it
cannot read past the end either way.

Reworded the comment — you're right that [1 0] implied two entries. It now
says the substitution explicitly: putting s -> 1-s into
dmin + s*(dmax-dmin) gives dmax + s*(dmin-dmax), which is the same pair
swapped, for any even length. Em dash replaced with a hyphen.

On invert_color deciding the order: I looked at this and I don't think it can
discriminate here. _get_image_mode returns mode == "CMYK" as invert_color,
so it is True for every DeviceCMYK image, and this branch already requires
img.mode == "CMYK". So inside it invert_color is always True and the order is
always the swapped one. That may be why your earlier commented-out attempt
had no clear condition — there isn't one at this point in the flow. If you
know of a CMYK DCT case where the values are not Adobe-inverted, that would
change it and I'd rather handle it explicitly than guess.

On the pixel assertion: good catch, the range check was weak. It now asserts
the exact raw CMYK samples, (121, 102, 114, 42) and (153, 137, 74, 82). I
used the raw values rather than an RGB conversion deliberately — they come
out of pypdf's own decode, so they don't move with Pillow's colour
management, whereas convert("RGB") does. Documented in the test.

Checked the test still bites: reverting the fix gives (134, 151, 142, 214)
for that pixel, roughly the complement.

The exact values come out of the JPEG decoder and can shift slightly
between libjpeg builds. The inversion this test guards against moves
every channel to its 255-complement, which is far larger than that,
so a tolerance keeps the regression pinned without tying the test to
one decoder build.
@RavSinghChandan

Copy link
Copy Markdown
Contributor Author

Both addressed.

On the swap order — I traced every path in _get_image_mode. The final
return is mode == "CMYK", and the two early returns that can produce
CMYK (/Separation, /DeviceN with a CMYK base) both hardcode True.
/Indexed with a CMYK base returns "P", so the img.mode == "CMYK" guard
here excludes it. So there's no path that reaches this branch with
invert_color False, and keying the order off it would be dead code.
Happy to add it if you know of a case I've missed.

On the pixel values — agreed, they can move between libjpeg builds.
Now asserted with a tolerance of 8, which is well inside the ~170-210
shift the missing inversion causes. Confirmed it still fails without
the fix: got (134, 151, 142, 214) against an expected (121, 102, 114, 42).

@stefan6419846
stefan6419846 merged commit 276309f into py-pdf:main Aug 11, 2026
35 of 48 checks passed
stefan6419846 added a commit that referenced this pull request Aug 13, 2026
## What's new

### Security (SEC)
- Detect cycles in TreeObject.insert_child (#3964) by @stefan6419846

### New Features (ENH)
- AppearanceStream: Allow arbitrary rotations and apply rotations for annotation appearance streams (#3917) by @PJBrs
- AppearanceStream: Consider more encodings for Type1 core fonts (#3905) by @PJBrs

### Performance Improvements (PI)
- Build /ToUnicode source-code bytes directly in parse_bfrange (#3952) by @Anai-Guo

### Bug Fixes (BUG)
- Preserve internal Link annotations when appending/merging (#3953) (#3954) by @Anai-Guo
- Fix false inline image end markers (#3922) by @ciphercom
- Reject typeless non-page /Kids entries in strict mode (#3955) by @Anai-Guo
- Apply character spacing (Tc) per glyph in layout-mode extraction (#3951) by @Anai-Guo
- Keep the Adobe CMYK inversion when an explicit /Decode is present (#3943) by @RavSinghChandan

### Robustness (ROB)
- AppearanceStream: Only escape parentheses for 8-bit fonts (#3961) by @PJBrs
- Recover the trailer from a cross-reference stream when rebuilding the xref (#3950) by @Anai-Guo

### Maintenance (MAINT)
- Deduplicate CatalogAttributes and CatalogDictionary (#3940) by @j-t-1

### Code Style (STY)
- Type pages as a Sequence rather than a list (#3957) by @RavSinghChandan

[Full Changelog](6.15.0...6.16.0)
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.

Inverted colors when extracting CMYK image

2 participants