Repository navigation
Description
What did you do?
I opened a run-length-encoded (type 2) Sun raster image whose rows hold an odd number of bytes (an 8-bit grayscale image 3 pixels wide) using Image.open() on an in-memory file created by the self-contained script below.
In this format every stored row is padded to an even number of bytes, and the RLE stream encodes the padding bytes too (SUN_RAS.TXT: "Regardless of width, the stored scanlines are rounded up to multiples of 16 bits", "A run is not terminated at the end of a scan line").
What did you expect to happen?
row 0: 16 32 48
row 1: 64 80 96
What actually happened?
The padding byte at the end of row 0 was decoded as the first pixel of row 1, and every following row is shifted one more byte, without any error:
row 0: 16 32 48
row 1: 0 64 80
Any image whose pixels take an odd number of bytes per row is affected (8-bit and 24-bit images of odd width; 1-bit images 1–8, 17–24, … pixels wide; 4-bit images 1–2, 5–6, … pixels wide). Images with even-length rows decode correctly as well as uncompressed (type 1) Sun raster files.
What are your OS, Python and Pillow versions?
- OS: Ubuntu 24.04, x86_64
- Python: 3.12.3
- Pillow: 13.0.0; also reproduced with 12.3.0 and current main (13.1.0.dev0, 1c5bdeb)
--------------------------------------------------------------------
Pillow 13.0.0
Python 3.12.3 (main, Aug 31 2026, 10:18:26) [GCC 13.3.0]
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 13.0.0
*** TKINTER support not installed
--- FREETYPE2 support ok, loaded 2.14.3
--- LITTLECMS2 support ok, loaded 2.19
--- WEBP support ok, loaded 1.6.0
--- AVIF support ok, loaded 1.4.2
--- JPEG support ok, compiled for libjpeg-turbo 3.2.0
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4
--- ZLIB (PNG/ZIP) support ok, loaded 1.3, compiled for zlib-ng 2.3.3
--- LIBTIFF support ok, loaded 4.7.2
--- RAQM (Bidirectional Text) support ok, loaded 0.11.0, fribidi 1.0.13, harfbuzz 14.5.0
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------
import io
import struct
from PIL import Image
# 8-bit grayscale, 3x2 pixels, type 2 (run-length encoded).
# Each stored row is 3 pixel bytes + 1 padding byte.
header = struct.pack(">8I", 0x59A66A95, 3, 2, 8, 8, 2, 0, 0)
data = bytes([0x10, 0x20, 0x30, 0x00, # row 0: pixels 16 32 48, padding
0x40, 0x50, 0x60, 0x00]) # row 1: pixels 64 80 96, padding
with Image.open(io.BytesIO(header + data)) as im:
rows = [[im.getpixel((x, y)) for x in range(3)] for y in range(2)]
print("decoded :", rows)
print("expected:", [[0x10, 0x20, 0x30], [0x40, 0x50, 0x60]])Output:
decoded : [[16, 32, 48], [0, 64, 80]]
expected: [[16, 32, 48], [64, 80, 96]]
Passing the padded stride to the sun_rle decoder (the same stride the raw path already computes) fixes it in my testing — happy to send a PR.
Found by genproof.com Formal Verification Agent. Reviewed and approved by a human.
Activity
hamarshehmhmd commentedon Oct 31, 2026
Reproduced this on 13.0.0 (your script gives
[[16, 32, 48], [0, 64, 80]]).Root cause is in
src/libImaging/SunRleDecode.c: the decoder treats a row as complete whenstate->x >= state->bytes(line 104), andstate->bytesis the unpadded row size from_setimageindecode.c((bits * xsize + 7) / 8).SunImagePluginalready rounds the stride up to 16 bits for the raw tile (line 108), but thesun_rletile has no stride, so the padding byte that the RLE stream contains is read as the first byte of the next row.Fix I tested: keep counting up to the padded stride, but only write bytes that fall inside the real row into the buffer (the buffer is still
state->byteslong):What I checked, after building the 13.0.0 sdist with this patch:
[[16, 32, 48], [64, 80, 96]]0x80bytes. Each one decodes to the same bytes as the type-1 (raw) version of the same data. Stock 13.0.0 fails some of these.MARBLES.SUNfrom test-images (1419 px wide, 24-bit, so rows are odd) as RLE. The patched build decodes it the same as the raw file; stock doesn't.sunraster/files in test-images (including thelena-*-rle.sunones, which have even rows) decode exactly as before, andTests/test_file_sun.pypasses (apart fromtest_im1, which only failed because I built without zlib).radarhere commentedon Nov 1, 2026
I found https://netghost.narod.ru/gff/sample/images/sunras/index.htm, containing https://netghost.narod.ru/gff/sample/images/sunras/marbles.sun, an image with a depth of 24 and a width of 1419. Using Pillow main, I opened it and saved it as a PNG.
Using ImageMagick, I converted marbles.sun to a PNG, and received an identical image, pixel for pixel.
It would appear that ImageMagick thinks we're doing the correct thing. Any thoughts?
KirillZiborov commentedon Nov 1, 2026
That file isn't actually RLE,
marbles.sunis type 1, so it goes through the raw path which already accounts for the padding and decodes fine. The issue in report is only about type 2.The same pixels re-encoded as RLE:
On 13.0.0 this prints
(0, 12, 1419, 990), each row is shifted one byte further from row 12 on.