Repository: python-pillow/Pillow

Issue:
RLE8 compression support for bitmap images
### What did you do?
Attempted to open an RLE8-compressed bitmap image.
### What did you expect to happen?
The image to display correctly.
### What actually happened?
An IOError("Unsupported BMP compression (%d)" % file_info['compression']) was thrown.

We try to open the image in our main program:
```python
def load_image(self, path, canvas):
        try:
            image = Image.open(path)
```
This calls the BmpImageFile class from BmpImagePlugin.py, which attempts to display the file according to the compression types listed in the dictionary on line 63: 
```python
COMPRESSIONS = {
        'RAW': 0,
        'RLE8': 1,
        'RLE4': 2,
        'BITFIELDS': 3,
        'JPEG': 4,
        'PNG': 5
    }
    RAW, RLE8, RLE4, BITFIELDS, JPEG, PNG = 0, 1, 2, 3, 4, 5
```
After reading header data, the program continues; on [line 158](https://github.com/python-pillow/Pillow/blob/master/src/PIL/BmpImagePlugin.py#L158), an if statement checks:
```python
if file_info['compression'] == self.BITFIELDS:
```
which evaluates to false. So, the program then jumps to [line 190](https://github.com/python-pillow/Pillow/blob/master/src/PIL/BmpImagePlugin.py#L190), where it checks whether the file's compression type is RAW:
```python
elif file_info['compression'] == self.RAW:
```
 which also evaluates to false. This causes the program to jump the code block to [line 193](https://github.com/python-pillow/Pillow/blob/master/src/PIL/BmpImagePlugin.py#L193), where the exception is thrown, and causes our main program to fail.
```python
raise IOError("Unsupported BMP compression (%d)" %
```
The code that ultimately throws this exception is found at line 194, in the [BmpImagePlugin source.](https://github.com/python-pillow/Pillow/blob/master/src/PIL/BmpImagePlugin.py)

### What are your OS, Python and Pillow versions?

* OS: Windows 10
* Python: 3.6.3
* Pillow: 5.2.0

[testImages.zip](https://github.com/python-pillow/Pillow/files/2498557/testImages.zip)


Discussion:
Just had this problem, for a single BMP file in a directory of otherwise supported files... to avoid using another package just for this file I made a quick and dirty implementation:

```python
def loadBmp(f):
    data = f.read()
    
    magic,size,_,_,pixel_ofs = struct.unpack_from("<2sIHHI", data, 0)
    # print (magic,size,pixel_ofs)
    
    if magic != b"BM" or size != len(data):
        raise Exception("not a BMP file")

    headersize,width,height,planes,bits,compression,img_size,ppm_hor,ppm_ver,num_colours,req_colours = struct.unpack_from("<IIIHHIIIIII", data, 14)
    # print (headersize,width,height,planes,bits,compression,img_size,ppm_hor,ppm_ver,num_colours,req_colours)
    if headersize != 40:
        raise Exception("only BITMAPINFOHEADER supported")

    if compression != 1:
        raise Exception("only RLE8 compression supported")
        
    pal = data[14+40:14+40+num_colours*4]
    bitmap = array.array("B", [0] * width * height)
    ofs = pixel_ofs
    x = 0
    y = 0
    while ofs < len(data) and y < height:
        cmd = data[ofs]
        ofs += 1
        if cmd == 0:
            op = data[ofs]
            ofs += 1
            if op == 0:
                # end of line
                x = 0
                y += 1
            elif op == 1:
                # end of bitmap
                ofs = len(data)
            elif op == 2:
                # delta
                delta = struct.unpack_from("<H", data, ofs)
                pos = x + y * width + delta
                x = pos % width
                y = pos // width
            else:
                for i in range(op):
                    bitmap[y*width+x] = data[ofs]
                    ofs += 1
                    x += 1
        else:
            val = data[ofs]
            ofs += 1
            for i in range(cmd):
                bitmap[y*width+x] = val
                x += 1
    im = Image.frombytes("P", (width, height), bytes(bitmap))
    im.putpalette(pal, "RGBX")
    return im
```

This is based on the official Microsoft documentation: 
https://docs.microsoft.com/en-us/windows/win32/gdi/bitmap-compression
For the record, test2.bmp is actually BITFIELDS.

I've created PR #6102 to resolve this.

Resolving patch:
diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
--- 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)
 


Test patch:
diff --git a/Tests/images/hopper_rle8.bmp b/Tests/images/hopper_rle8.bmp
new file mode 100644
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
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
--- 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
--- 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