Skip to content

BUG: Decode low-bit DeviceRGB images as RGB instead of palette - #3929

Merged
stefan6419846 merged 1 commit into
py-pdf:mainfrom
RavSinghChandan:fix-4bit-rgb-image-mode
Aug 4, 2026
Merged

stefan6419846 merged 1 commit into
py-pdf:mainfrom
RavSinghChandan:fix-4bit-rgb-image-mode

Conversation

@RavSinghChandan

Copy link
Copy Markdown
Contributor

Fixes #3924.

A 4-bit (or 2-bit) per component /DeviceRGB image was being forced to
Pillow mode "P", which left the unrecognized "4bits"/"2bits" mode string
and broke extraction (either an error or a wrong-looking image).

The low-bit branch in _handle_flate assumed a single component per pixel.
For /DeviceRGB the samples are 3 interleaved color components, so this now:

  • unpacks all width * colors samples per row (not just width)
  • scales each n-bit sample up to the full 0-255 range (e.g. 4-bit 15 -> 255)
  • sets the mode to "RGB"

Single-component color spaces (e.g. /DeviceGray) still map to "P", so the
existing 4-bit palette path is unchanged. bits2byte keeps its old
signature via defaults (colors=1, scale=False).

Added an offline test covering a 2x2 4-bit DeviceRGB image and confirming a
single-component image still resolves to "P". @stefan6419846 — happy to have
you run this against your original file to confirm it resolves your case too.

@RavSinghChandan RavSinghChandan changed the title fix: decode low-bit DeviceRGB images as RGB instead of palette BUG: Decode low-bit DeviceRGB images as RGB instead of palette Jul 27, 2026
@RavSinghChandan

Copy link
Copy Markdown
Contributor Author

The failing jobs look like the usual network flakes on the download-fixture tests (there's a ConnectionResetError fetching the sample PDFs) — my added test is offline and passes. Happy to rebase or re-run whenever CI settles. @stefan6419846 let me know if you'd like me to run the fix against your original file.

@codecov

codecov Bot commented Jul 27, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.87%. Comparing base (7d80373) to head (ac5072a).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3929   +/-   ##
=======================================
  Coverage   97.87%   97.87%           
=======================================
  Files          57       57           
  Lines       10779    10782    +3     
  Branches     2019     2019           
=======================================
+ Hits        10550    10553    +3     
  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 an inline comment.

Regarding my original case, this change does not solve it - most likely because the inline image section does not use a filter on the image data, while in your case, it seems like it is about the /FlateDecode filter.

Comment thread pypdf/generic/_image_xobject.py Resolved
@RavSinghChandan

Copy link
Copy Markdown
Contributor Author

Good catch on both counts.

Took the int(mode[0]) suggestion — cleaner, and the only low-bit modes are
2bits/4bits so it can't surprise us.

On your case: you're right, and I can reproduce it. The expansion sits in
_handle_flate, which only runs for FlateDecode/RunLengthDecode, so an
unfiltered image falls through to the plain _image_from_bytes call at the
bottom and the raw "4bits" mode goes straight to Pillow. Same for the
LZW/ASCII85 fallback and the CMYK branch — four call sites in total that
never see the conversion.

Looks like the right place is _image_from_bytes itself, since every path
goes through it. Moving it there fixes the unfiltered case here and keeps
the flate path behaving the same.

Want me to do that in this PR, or keep this one to the flate fix for #3924
and handle the inline-image path separately? Happy either way — the second
one touches more paths so I didn't want to widen the scope without asking.

@stefan6419846

Copy link
Copy Markdown
Collaborator

Want me to do that in this PR, or keep this one to the flate fix for #3924 and handle the inline-image path separately? Happy either way — the second one touches more paths so I didn't want to widen the scope without asking.

How would this affect the new parameters you introduced in the current PR? Would they still be relevant for a general fix, or would a general fix revert these?

@RavSinghChandan

Copy link
Copy Markdown
Contributor Author

They'd stay as-is — a general fix doesn't revert them.

The two params are doing the actual work: colors= makes bits2byte unpack
width * colors samples per row instead of assuming one component per pixel,
and scale= stretches each sample to 0-255 when it's read as colour rather
than a palette index. Any fix still needs both, wherever it's called from.

What moves is only the dispatch — the if mode in ("2bits", "4bits") block
that picks the bit depth and the resulting mode. Right now it sits in
_handle_flate, so only FlateDecode/RunLengthDecode ever reach it. Moving it
into _image_from_bytes means every path gets it, and bits2byte stays exactly
as it is in this PR.

I tried it locally: same 4-bit DeviceRGB image comes out as RGB
[(255,0,0), (255,0,0), (255,255,0), (255,255,0)] both with and without the
Flate filter, where the unfiltered one currently raises "unrecognized image
mode". colors is already computed in _xobj_to_image before all four
_image_from_bytes calls, so nothing new needs threading through.

So it's additive rather than a revert. Happy to fold it into this PR if
you'd like it all in one, or land this and do the move separately — your
call.

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

I guess we can make this two separate PRs if you are planning to work on preparing the second PR after this.

For this to get merged, please resolve the merge conflict.

4-bit and 2-bit /DeviceRGB images were forced to mode 'P', which left an
unrecognized '4bits'/'2bits' Pillow mode and produced broken extraction.
Treat multi-component DeviceRGB samples as interleaved color components:
unpack them per row across all components and scale up to 8-bit, giving a
proper RGB image. Single-component spaces still map to 'P'.
@RavSinghChandan
RavSinghChandan force-pushed the fix-4bit-rgb-image-mode branch from bab265e to ac5072a Compare August 4, 2026 14:35
@RavSinghChandan

Copy link
Copy Markdown
Contributor Author

Rebased on main — the conflict was with the _handle_flate reorder in #3904.
Kept the new ordering and moved the low-bit branch into it; the /Indexed
unpacking has to stay above it since the branch reads color_space.

Happy to do the general _image_from_bytes fix as a separate PR once this
lands.

@stefan6419846
stefan6419846 merged commit 3fa890e into py-pdf:main Aug 4, 2026
33 of 34 checks passed
stefan6419846 added a commit that referenced this pull request Aug 6, 2026
## What's new

### Security (SEC)
- Improve performance of read_until_whitespace (#3947) by @stefan6419846
- Limit CID width range and width count when loading fonts (#3946) by @stefan6419846
- Limit token length for ToUnicode entries (#3944) by @stefan6419846

### Deprecations (DEP)
- Deprecate previously missed inline_images setter as well (#3897) by @stefan6419846

### New Features (ENH)
- Add ActualText tag when we produce RTL appearance stream (#3901) by @PJBrs
- Allow keeping original PDF header in non-incremental clone mode (#3918) by @pctablet505
- AppearanceStream: Add support for RTL appearance streams with Arabic characters (#3831) by @PJBrs
- Add actions base class (#3552) by @j-t-1

### Bug Fixes (BUG)
- Expand low-bit samples for images without a filter (#3938) by @RavSinghChandan
- Decode low-bit DeviceRGB images as RGB instead of palette (#3929) by @RavSinghChandan
- Correctly handle IndirectObject in FlateDecode.decode DecodeParms (#3937) by @stefan6419846

### Documentation (DOC)
- Image and attachment filenames are unsanitized (#3942) by @stefan6419846

### Maintenance (MAINT)
- Update charset mappings (#3935) by @stefan6419846

[Full Changelog](6.14.2...6.15.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.

Unrecognized image mode 4bits for RGB images

2 participants