Skip to content

Fix RLE4 absolute mode decoding for odd pixel counts#9710

Open
nyxst4ck wants to merge 2 commits into
python-pillow:mainfrom
nyxst4ck:fix-rle4-absolute-odd
Open

Fix RLE4 absolute mode decoding for odd pixel counts#9710
nyxst4ck wants to merge 2 commits into
python-pillow:mainfrom
nyxst4ck:fix-rle4-absolute-odd

Conversation

@nyxst4ck

Copy link
Copy Markdown
Contributor

Changes proposed in this pull request

  • Fix RLE4 absolute-mode decoding when an absolute run contains an odd number of pixels.
  • Add a regression test (Tests/test_file_bmp.py::test_rle4_absolute_odd).

Bug

In a BMP BI_RLE4 absolute run, the count byte specifies a number of 4-bit pixels. Those pixels are packed two per byte and the run is padded up to a whole byte (and then to a 16-bit word boundary). When the count is odd, the run still occupies ceil(count / 2) bytes — the last nibble is padding.

The decoder computed the number of bytes to read with floor division:

byte_count = byte[0] // 2

For an odd count (e.g. 3 pixels) this reads one byte too few (3 // 2 == 1). As a result:

  1. The final pixel of the run is dropped.
  2. The file pointer is left one byte short, so the subsequent word-alignment and the rest of the RLE stream are read from the wrong offset.

In practice the desync causes the decode to terminate early and raise ValueError: not enough image data.

Root cause and fix

src/PIL/BmpImagePlugin.py, absolute-mode branch of BmpRleDecoder.decode:

# before
byte_count = byte[0] // 2
bytes_read = self.fd.read(byte_count)
for byte_read in bytes_read:
    data += o8(byte_read >> 4)
    data += o8(byte_read & 0x0F)

# after
byte_count = (byte[0] + 1) // 2
bytes_read = self.fd.read(byte_count)
for i, byte_read in enumerate(bytes_read):
    data += o8(byte_read >> 4)
    if 2 * i + 1 < byte[0]:
        data += o8(byte_read & 0x0F)

(byte[0] + 1) // 2 reads the correct (ceil) number of bytes, and the 2 * i + 1 < byte[0] guard emits exactly byte[0] pixels so the padding nibble of an odd run is discarded rather than turned into an extra pixel.

Reproduction (red → green)

The new test builds a minimal 3×1 RLE4 BMP whose single row is one absolute run of 3 pixels (palette indices 1, 2, 3).

  • On main it fails: ValueError: not enough image data.
  • With this change it passes: the row decodes to [1, 2, 3].

The existing test_rle4 (which uses Tests/images/bmp/g/pal4rle.bmp) and the rest of Tests/test_file_bmp.py continue to pass (33 passed). Even-count absolute runs are unaffected. ruff and black are clean on the changed files.

In an RLE4 absolute run, the count byte gives a number of 4-bit pixels
packed two per byte and padded up to a whole byte. The decoder divided
the count by two with floor division, so an odd count read one byte too
few: the final pixel was dropped and the file pointer was left
misaligned, desyncing the rest of the stream and typically raising
"not enough image data".

Read ceil(count / 2) bytes instead, and emit exactly `count` pixels so
the trailing padding nibble of an odd run is discarded.
@radarhere radarhere added the BMP label Jun 22, 2026
@radarhere

Copy link
Copy Markdown
Member

For the record, a specification reference for this is https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/73b57f24-6d78-4eeb-9c06-8f892d88f1ab

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants