diff --git a/Tests/images/hopper_rle8.bmp b/Tests/images/hopper_rle8.bmp new file mode 100644 index 00000000000..0fff4a0d43d Binary files /dev/null and b/Tests/images/hopper_rle8.bmp differ diff --git a/Tests/images/hopper_rle8_row_overflow.bmp b/Tests/images/hopper_rle8_row_overflow.bmp new file mode 100644 index 00000000000..d606dc3e41a Binary files /dev/null and b/Tests/images/hopper_rle8_row_overflow.bmp differ diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 440bc325b45..b17aad2ea50 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -40,6 +40,7 @@ def test_questionable(): "rgb32fakealpha.bmp", "rgb24largepal.bmp", "pal8os2sp.bmp", + "pal8rletrns.bmp", "rgb32bf-xbgr.bmp", ] for f in get_files("q"): diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 47fc97df055..f214fd6bda1 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -4,7 +4,12 @@ from PIL import BmpImagePlugin, Image -from .helper import assert_image_equal, assert_image_equal_tofile, hopper +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + assert_image_similar_tofile, + hopper, +) def test_sanity(tmp_path): @@ -125,6 +130,42 @@ def test_rgba_bitfields(): assert_image_equal_tofile(im, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") +def test_rle8(): + with Image.open("Tests/images/hopper_rle8.bmp") as im: + assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) + + # This test image has been manually hexedited + # to have rows with too much data + with Image.open("Tests/images/hopper_rle8_row_overflow.bmp") as im: + assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) + + # Signal end of bitmap before the image is finished + with open("Tests/images/bmp/g/pal8rle.bmp", "rb") as fp: + data = fp.read(1063) + b"\x01" + with Image.open(io.BytesIO(data)) as im: + with pytest.raises(ValueError): + im.load() + + +@pytest.mark.parametrize( + "file_name,length", + ( + # EOF immediately after the header + ("Tests/images/hopper_rle8.bmp", 1078), + # EOF during delta + ("Tests/images/bmp/q/pal8rletrns.bmp", 3670), + # EOF when reading data in absolute mode + ("Tests/images/bmp/g/pal8rle.bmp", 1064), + ), +) +def test_rle8_eof(file_name, length): + with open(file_name, "rb") as fp: + data = fp.read(length) + with Image.open(io.BytesIO(data)) as im: + with pytest.raises(ValueError): + im.load() + + def test_offset(): # This image has been hexedited # to exclude the palette size from the pixel data offset diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 72e40b05f66..7e7e742cd74 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -24,6 +24,8 @@ # +import os + from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 from ._binary import i32le as i32 @@ -167,6 +169,7 @@ def _bitmap(self, header=0, offset=0): raise OSError(f"Unsupported BMP pixel depth ({file_info['bits']})") # ---------------- Process BMP with Bitfields compression (not palette) + decoder_name = "raw" if file_info["compression"] == self.BITFIELDS: SUPPORTED = { 32: [ @@ -208,6 +211,8 @@ def _bitmap(self, header=0, offset=0): elif file_info["compression"] == self.RAW: if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset raw_mode, self.mode = "BGRA", "RGBA" + elif file_info["compression"] == self.RLE8: + decoder_name = "bmp_rle" else: raise OSError(f"Unsupported BMP compression ({file_info['compression']})") @@ -247,7 +252,7 @@ def _bitmap(self, header=0, offset=0): self.info["compression"] = file_info["compression"] self.tile = [ ( - "raw", + decoder_name, (0, 0, file_info["width"], file_info["height"]), offset or self.fp.tell(), ( @@ -271,6 +276,57 @@ def _open(self): self._bitmap(offset=offset) +class BmpRleDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer): + data = bytearray() + x = 0 + while len(data) < self.state.xsize * self.state.ysize: + pixels = self.fd.read(1) + byte = self.fd.read(1) + if not pixels or not byte: + break + num_pixels = pixels[0] + if num_pixels: + # encoded mode + if x + num_pixels > self.state.xsize: + # Too much data for row + num_pixels = max(0, self.state.xsize - x) + data += byte * num_pixels + x += num_pixels + else: + if byte[0] == 0: + # end of line + while len(data) % self.state.xsize != 0: + data += b"\x00" + x = 0 + elif byte[0] == 1: + # end of bitmap + break + elif byte[0] == 2: + # delta + bytes_read = self.fd.read(2) + if len(bytes_read) < 2: + break + right, up = self.fd.read(2) + data += b"\x00" * (right + up * self.state.xsize) + x = len(data) % self.state.xsize + else: + # absolute mode + bytes_read = self.fd.read(byte[0]) + data += bytes_read + if len(bytes_read) < byte[0]: + break + x += byte[0] + + # align to 16-bit word boundary + if self.fd.tell() % 2 != 0: + self.fd.seek(1, os.SEEK_CUR) + self.set_as_raw(bytes(data), ("P", 0, self.args[-1])) + return -1, 0 + + # ============================================================================= # Image plugin for the DIB format (BMP alias) # ============================================================================= @@ -372,6 +428,8 @@ def _save(im, fp, filename, bitmap_header=True): Image.register_mime(BmpImageFile.format, "image/bmp") +Image.register_decoder("bmp_rle", BmpRleDecoder) + Image.register_open(DibImageFile.format, DibImageFile, _dib_accept) Image.register_save(DibImageFile.format, _dib_save)