Repository: python-pillow/Pillow

Issue:
Saving BLP files
Any chance for saving BLP1 files being added in the near future?



Discussion:
There's partial support for reading BLP1: 
https://pillow.readthedocs.io/en/5.1.x/handbook/image-file-formats.html?highlight=blp1#blp

There's no plans for further support, but pull requests are welcome. Please see: 
https://github.com/python-pillow/Pillow/blob/master/.github/CONTRIBUTING.md.
i would make a pull request if I had any idea of how images work :P

Resolving patch:
diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py
--- a/src/PIL/BlpImagePlugin.py
+++ b/src/PIL/BlpImagePlugin.py
@@ -29,6 +29,7 @@
   - DXT5 compression is used if alpha_encoding == 7.
 """
 
+import os
 import struct
 import warnings
 from enum import IntEnum
@@ -266,6 +267,10 @@ class BLPFormatError(NotImplementedError):
     pass
 
 
+def _accept(prefix):
+    return prefix[:4] in (b"BLP1", b"BLP2")
+
+
 class BlpImageFile(ImageFile.ImageFile):
     """
     Blizzard Mipmap Format
@@ -276,36 +281,20 @@ class BlpImageFile(ImageFile.ImageFile):
 
     def _open(self):
         self.magic = self.fp.read(4)
-        self._read_blp_header()
-
-        if self.magic == b"BLP1":
-            decoder = "BLP1"
-            self.mode = "RGB"
-        elif self.magic == b"BLP2":
-            decoder = "BLP2"
-            self.mode = "RGBA" if self._blp_alpha_depth else "RGB"
-        else:
-            raise BLPFormatError(f"Bad BLP magic {repr(self.magic)}")
-
-        self.tile = [(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))]
 
-    def _read_blp_header(self):
-        (self._blp_compression,) = struct.unpack("<i", self.fp.read(4))
-
-        (self._blp_encoding,) = struct.unpack("<b", self.fp.read(1))
+        self.fp.seek(5, os.SEEK_CUR)
         (self._blp_alpha_depth,) = struct.unpack("<b", self.fp.read(1))
-        (self._blp_alpha_encoding,) = struct.unpack("<b", self.fp.read(1))
-        (self._blp_mips,) = struct.unpack("<b", self.fp.read(1))
 
+        self.fp.seek(2, os.SEEK_CUR)
         self._size = struct.unpack("<II", self.fp.read(8))
 
-        if self.magic == b"BLP1":
-            # Only present for BLP1
-            (self._blp_encoding,) = struct.unpack("<i", self.fp.read(4))
-            (self._blp_subtype,) = struct.unpack("<i", self.fp.read(4))
+        if self.magic in (b"BLP1", b"BLP2"):
+            decoder = self.magic.decode()
+        else:
+            raise BLPFormatError(f"Bad BLP magic {repr(self.magic)}")
 
-        self._blp_offsets = struct.unpack("<16I", self.fp.read(16 * 4))
-        self._blp_lengths = struct.unpack("<16I", self.fp.read(16 * 4))
+        self.mode = "RGBA" if self._blp_alpha_depth else "RGB"
+        self.tile = [(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))]
 
 
 class _BLPBaseDecoder(ImageFile.PyDecoder):
@@ -313,45 +302,59 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
 
     def decode(self, buffer):
         try:
-            self.fd.seek(0)
-            self.magic = self.fd.read(4)
             self._read_blp_header()
             self._load()
         except struct.error as e:
-            raise OSError("Truncated Blp file") from e
+            raise OSError("Truncated BLP file") from e
         return 0, 0
 
-    def _safe_read(self, length):
-        return ImageFile._safe_read(self.fd, length)
-
-    def _read_palette(self):
-        ret = []
-        for i in range(256):
-            try:
-                b, g, r, a = struct.unpack("<4B", self._safe_read(4))
-            except struct.error:
-                break
-            ret.append((b, g, r, a))
-        return ret
-
     def _read_blp_header(self):
+        self.fd.seek(4)
         (self._blp_compression,) = struct.unpack("<i", self._safe_read(4))
 
         (self._blp_encoding,) = struct.unpack("<b", self._safe_read(1))
         (self._blp_alpha_depth,) = struct.unpack("<b", self._safe_read(1))
         (self._blp_alpha_encoding,) = struct.unpack("<b", self._safe_read(1))
-        (self._blp_mips,) = struct.unpack("<b", self._safe_read(1))
+        self.fd.seek(1, os.SEEK_CUR)  # mips
 
         self.size = struct.unpack("<II", self._safe_read(8))
 
-        if self.magic == b"BLP1":
+        if isinstance(self, BLP1Decoder):
             # Only present for BLP1
             (self._blp_encoding,) = struct.unpack("<i", self._safe_read(4))
-            (self._blp_subtype,) = struct.unpack("<i", self._safe_read(4))
+            self.fd.seek(4, os.SEEK_CUR)  # subtype
 
         self._blp_offsets = struct.unpack("<16I", self._safe_read(16 * 4))
         self._blp_lengths = struct.unpack("<16I", self._safe_read(16 * 4))
 
+    def _safe_read(self, length):
+        return ImageFile._safe_read(self.fd, length)
+
+    def _read_palette(self):
+        ret = []
+        for i in range(256):
+            try:
+                b, g, r, a = struct.unpack("<4B", self._safe_read(4))
+            except struct.error:
+                break
+            ret.append((b, g, r, a))
+        return ret
+
+    def _read_bgra(self, palette):
+        data = bytearray()
+        _data = BytesIO(self._safe_read(self._blp_lengths[0]))
+        while True:
+            try:
+                (offset,) = struct.unpack("<B", _data.read(1))
+            except struct.error:
+                break
+            b, g, r, a = palette[offset]
+            d = (r, g, b)
+            if self._blp_alpha_depth:
+                d += (a,)
+            data.extend(d)
+        return data
+
 
 class BLP1Decoder(_BLPBaseDecoder):
     def _load(self):
@@ -360,17 +363,8 @@ def _load(self):
 
         elif self._blp_compression == 1:
             if self._blp_encoding in (4, 5):
-                data = bytearray()
                 palette = self._read_palette()
-                _data = BytesIO(self._safe_read(self._blp_lengths[0]))
-                while True:
-                    try:
-                        (offset,) = struct.unpack("<B", _data.read(1))
-                    except struct.error:
-                        break
-                    b, g, r, a = palette[offset]
-                    data.extend([r, g, b])
-
+                data = self._read_bgra(palette)
                 self.set_as_raw(bytes(data))
             else:
                 raise BLPFormatError(
@@ -401,23 +395,16 @@ class BLP2Decoder(_BLPBaseDecoder):
     def _load(self):
         palette = self._read_palette()
 
-        data = bytearray()
         self.fd.seek(self._blp_offsets[0])
 
         if self._blp_compression == 1:
             # Uncompressed or DirectX compression
 
             if self._blp_encoding == Encoding.UNCOMPRESSED:
-                _data = BytesIO(self._safe_read(self._blp_lengths[0]))
-                while True:
-                    try:
-                        (offset,) = struct.unpack("<B", _data.read(1))
-                    except struct.error:
-                        break
-                    b, g, r, a = palette[offset]
-                    data.extend((r, g, b))
+                data = self._read_bgra(palette)
 
             elif self._blp_encoding == Encoding.DXT:
+                data = bytearray()
                 if self._blp_alpha_encoding == AlphaEncoding.DXT1:
                     linesize = (self.size[0] + 3) // 4 * 8
                     for yb in range((self.size[1] + 3) // 4):
@@ -452,12 +439,59 @@ def _load(self):
         self.set_as_raw(bytes(data))
 
 
-def _accept(prefix):
-    return prefix[:4] in (b"BLP1", b"BLP2")
+class BLPEncoder(ImageFile.PyEncoder):
+    _pushes_fd = True
+
+    def _write_palette(self):
+        data = b""
+        palette = self.im.getpalette("RGBA", "RGBA")
+        for i in range(256):
+            r, g, b, a = palette[i * 4 : (i + 1) * 4]
+            data += struct.pack("<4B", b, g, r, a)
+        return data
+
+    def encode(self, bufsize):
+        palette_data = self._write_palette()
+
+        offset = 20 + 16 * 4 * 2 + len(palette_data)
+        data = struct.pack("<16I", offset, *((0,) * 15))
+
+        w, h = self.im.size
+        data += struct.pack("<16I", w * h, *((0,) * 15))
+
+        data += palette_data
+
+        for y in range(h):
+            for x in range(w):
+                data += struct.pack("<B", self.im.getpixel((x, y)))
+
+        return len(data), 0, data
+
+
+def _save(im, fp, filename, save_all=False):
+    if im.mode != "P":
+        raise ValueError("Unsupported BLP image mode")
+
+    magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
+    fp.write(magic)
+
+    fp.write(struct.pack("<i", 1))  # Uncompressed or DirectX compression
+    fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
+    fp.write(struct.pack("<b", 1 if im.palette.mode == "RGBA" else 0))
+    fp.write(struct.pack("<b", 0))  # alpha encoding
+    fp.write(struct.pack("<b", 0))  # mips
+    fp.write(struct.pack("<II", *im.size))
+    if magic == b"BLP1":
+        fp.write(struct.pack("<i", 5))
+        fp.write(struct.pack("<i", 0))
+
+    ImageFile._save(im, fp, [("BLP", (0, 0) + im.size, 0, im.mode)])
 
 
 Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
 Image.register_extension(BlpImageFile.format, ".blp")
-
 Image.register_decoder("BLP1", BLP1Decoder)
 Image.register_decoder("BLP2", BLP2Decoder)
+
+Image.register_save(BlpImageFile.format, _save)
+Image.register_encoder("BLP", BLPEncoder)
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -49,7 +49,11 @@
     -8: "bad configuration",
     -9: "out of memory error",
 }
-"""Dict of known error codes returned from :meth:`.PyDecoder.decode`."""
+"""
+Dict of known error codes returned from :meth:`.PyDecoder.decode`,
+:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and
+:meth:`.PyEncoder.encode_to_file`.
+"""
 
 
 #
@@ -577,16 +581,7 @@ def extents(self):
         return (self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize)
 
 
-class PyDecoder:
-    """
-    Python implementation of a format decoder. Override this class and
-    add the decoding logic in the :meth:`decode` method.
-
-    See :ref:`Writing Your Own File Decoder in Python<file-decoders-py>`
-    """
-
-    _pulls_fd = False
-
+class PyCodec:
     def __init__(self, mode, *args):
         self.im = None
         self.state = PyCodecState()
@@ -596,31 +591,16 @@ def __init__(self, mode, *args):
 
     def init(self, args):
         """
-        Override to perform decoder specific initialization
+        Override to perform codec specific initialization
 
         :param args: Array of args items from the tile entry
         :returns: None
         """
         self.args = args
 
-    @property
-    def pulls_fd(self):
-        return self._pulls_fd
-
-    def decode(self, buffer):
-        """
-        Override to perform the decoding process.
-
-        :param buffer: A bytes object with the data to be decoded.
-        :returns: A tuple of ``(bytes consumed, errcode)``.
-            If finished with decoding return <0 for the bytes consumed.
-            Err codes are from :data:`.ImageFile.ERRORS`.
-        """
-        raise NotImplementedError()
-
     def cleanup(self):
         """
-        Override to perform decoder specific cleanup
+        Override to perform codec specific cleanup
 
         :returns: None
         """
@@ -628,16 +608,16 @@ def cleanup(self):
 
     def setfd(self, fd):
         """
-        Called from ImageFile to set the python file-like object
+        Called from ImageFile to set the Python file-like object
 
-        :param fd: A python file-like object
+        :param fd: A Python file-like object
         :returns: None
         """
         self.fd = fd
 
     def setimage(self, im, extents=None):
         """
-        Called from ImageFile to set the core output image for the decoder
+        Called from ImageFile to set the core output image for the codec
 
         :param im: A core image object
         :param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle
@@ -670,6 +650,32 @@ def setimage(self, im, extents=None):
         ):
             raise ValueError("Tile cannot extend outside image")
 
+
+class PyDecoder(PyCodec):
+    """
+    Python implementation of a format decoder. Override this class and
+    add the decoding logic in the :meth:`decode` method.
+
+    See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
+    """
+
+    _pulls_fd = False
+
+    @property
+    def pulls_fd(self):
+        return self._pulls_fd
+
+    def decode(self, buffer):
+        """
+        Override to perform the decoding process.
+
+        :param buffer: A bytes object with the data to be decoded.
+        :returns: A tuple of ``(bytes consumed, errcode)``.
+            If finished with decoding return 0 for the bytes consumed.
+            Err codes are from :data:`.ImageFile.ERRORS`.
+        """
+        raise NotImplementedError()
+
     def set_as_raw(self, data, rawmode=None):
         """
         Convenience method to set the internal image from a stream of raw data
@@ -690,3 +696,55 @@ def set_as_raw(self, data, rawmode=None):
             raise ValueError("not enough image data")
         if s[1] != 0:
             raise ValueError("cannot decode image data")
+
+
+class PyEncoder(PyCodec):
+    """
+    Python implementation of a format encoder. Override this class and
+    add the decoding logic in the :meth:`encode` method.
+    """
+
+    _pushes_fd = False
+
+    @property
+    def pushes_fd(self):
+        return self._pushes_fd
+
+    def encode(self, bufsize):
+        """
+        Override to perform the encoding process.
+
+        :param bufsize: Buffer size.
+        :returns: A tuple of ``(bytes encoded, errcode, bytes)``.
+            If finished with encoding return 1 for the error code.
+            Err codes are from :data:`.ImageFile.ERRORS`.
+        """
+        raise NotImplementedError()
+
+    def encode_to_pyfd(self):
+        """
+        :returns: A tuple of ``(bytes consumed, errcode)``.
+            Err codes are from :data:`.ImageFile.ERRORS`.
+        """
+        if not self.pushes_fd:
+            return 0, -8  # bad configuration
+        bytes_consumed, errcode, data = self.encode(0)
+        if data:
+            self.fd.write(data)
+        return bytes_consumed, errcode
+
+    def encode_to_file(self, fh, bufsize):
+        """
+        :param fh: File handle.
+        :param bufsize: Buffer size.
+
+        :returns: If finished successfully, return 0.
+            Otherwise, return an error code. Err codes are from
+            :data:`.ImageFile.ERRORS`.
+        """
+        errcode = 0
+        while errcode == 0:
+            status, errcode, buf = self.encode(bufsize)
+            if status > 0:
+                fh.write(buf[status:])
+        return errcode


Test patch:
diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py
--- a/Tests/test_file_blp.py
+++ b/Tests/test_file_blp.py
@@ -2,7 +2,12 @@
 
 from PIL import BlpImagePlugin, Image
 
-from .helper import assert_image_equal_tofile
+from .helper import (
+    assert_image_equal,
+    assert_image_equal_tofile,
+    assert_image_similar,
+    hopper,
+)
 
 
 def test_load_blp1():
@@ -25,6 +30,28 @@ def test_load_blp2_dxt1a():
         assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1a.png")
 
 
+def test_save(tmp_path):
+    f = str(tmp_path / "temp.blp")
+
+    for version in ("BLP1", "BLP2"):
+        im = hopper("P")
+        im.save(f, blp_version=version)
+
+        with Image.open(f) as reloaded:
+            assert_image_equal(im.convert("RGB"), reloaded)
+
+        with Image.open("Tests/images/transparent.png") as im:
+            f = str(tmp_path / "temp.blp")
+            im.convert("P").save(f, blp_version=version)
+
+            with Image.open(f) as reloaded:
+                assert_image_similar(im, reloaded, 8)
+
+    im = hopper()
+    with pytest.raises(ValueError):
+        im.save(f)
+
+
 @pytest.mark.parametrize(
     "test_file",
     [
diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py
--- a/Tests/test_imagefile.py
+++ b/Tests/test_imagefile.py
@@ -124,6 +124,23 @@ def test_negative_stride(self):
         with pytest.raises(OSError):
             p.close()
 
+    def test_no_format(self):
+        buf = BytesIO(b"\x00" * 255)
+
+        class DummyImageFile(ImageFile.ImageFile):
+            def _open(self):
+                self.mode = "RGB"
+                self._size = (1, 1)
+
+        im = DummyImageFile(buf)
+        assert im.format is None
+        assert im.get_format_mimetype() is None
+
+    def test_oserror(self):
+        im = Image.new("RGB", (1, 1))
+        with pytest.raises(OSError):
+            im.save(BytesIO(), "JPEG2000", num_resolutions=2)
+
     def test_truncated(self):
         b = BytesIO(
             b"BM000000000000"  # head_data
@@ -179,6 +196,11 @@ def decode(self, buffer):
         return -1, 0
 
 
+class MockPyEncoder(ImageFile.PyEncoder):
+    def encode(self, buffer):
+        return 1, 1, b""
+
+
 xoff, yoff, xsize, ysize = 10, 20, 100, 100
 
 
@@ -190,53 +212,58 @@ def _open(self):
         self.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)]
 
 
-class TestPyDecoder:
-    def get_decoder(self):
-        decoder = MockPyDecoder(None)
+class CodecsTest:
+    @classmethod
+    def setup_class(cls):
+        cls.decoder = MockPyDecoder(None)
+        cls.encoder = MockPyEncoder(None)
 
-        def closure(mode, *args):
-            decoder.__init__(mode, *args)
-            return decoder
+        def decoder_closure(mode, *args):
+            cls.decoder.__init__(mode, *args)
+            return cls.decoder
 
-        Image.register_decoder("MOCK", closure)
-        return decoder
+        def encoder_closure(mode, *args):
+            cls.encoder.__init__(mode, *args)
+            return cls.encoder
 
+        Image.register_decoder("MOCK", decoder_closure)
+        Image.register_encoder("MOCK", encoder_closure)
+
+
+class TestPyDecoder(CodecsTest):
     def test_setimage(self):
         buf = BytesIO(b"\x00" * 255)
 
         im = MockImageFile(buf)
-        d = self.get_decoder()
 
         im.load()
 
-        assert d.state.xoff == xoff
-        assert d.state.yoff == yoff
-        assert d.state.xsize == xsize
-        assert d.state.ysize == ysize
+        assert self.decoder.state.xoff == xoff
+        assert self.decoder.state.yoff == yoff
+        assert self.decoder.state.xsize == xsize
+        assert self.decoder.state.ysize == ysize
 
         with pytest.raises(ValueError):
-            d.set_as_raw(b"\x00")
+            self.decoder.set_as_raw(b"\x00")
 
     def test_extents_none(self):
         buf = BytesIO(b"\x00" * 255)
 
         im = MockImageFile(buf)
         im.tile = [("MOCK", None, 32, None)]
-        d = self.get_decoder()
 
         im.load()
 
-        assert d.state.xoff == 0
-        assert d.state.yoff == 0
-        assert d.state.xsize == 200
-        assert d.state.ysize == 200
+        assert self.decoder.state.xoff == 0
+        assert self.decoder.state.yoff == 0
+        assert self.decoder.state.xsize == 200
+        assert self.decoder.state.ysize == 200
 
     def test_negsize(self):
         buf = BytesIO(b"\x00" * 255)
 
         im = MockImageFile(buf)
         im.tile = [("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)]
-        self.get_decoder()
 
         with pytest.raises(ValueError):
             im.load()
@@ -250,7 +277,6 @@ def test_oversize(self):
 
         im = MockImageFile(buf)
         im.tile = [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None)]
-        self.get_decoder()
 
         with pytest.raises(ValueError):
             im.load()
@@ -259,14 +285,90 @@ def test_oversize(self):
         with pytest.raises(ValueError):
             im.load()
 
-    def test_no_format(self):
+    def test_decode(self):
+        decoder = ImageFile.PyDecoder(None)
+        with pytest.raises(NotImplementedError):
+            decoder.decode(None)
+
+
+class TestPyEncoder(CodecsTest):
+    def test_setimage(self):
         buf = BytesIO(b"\x00" * 255)
 
         im = MockImageFile(buf)
-        assert im.format is None
-        assert im.get_format_mimetype() is None
 
-    def test_oserror(self):
-        im = Image.new("RGB", (1, 1))
-        with pytest.raises(OSError):
-            im.save(BytesIO(), "JPEG2000", num_resolutions=2)
+        fp = BytesIO()
+        ImageFile._save(
+            im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")]
+        )
+
+        assert self.encoder.state.xoff == xoff
+        assert self.encoder.state.yoff == yoff
+        assert self.encoder.state.xsize == xsize
+        assert self.encoder.state.ysize == ysize
+
+    def test_extents_none(self):
+        buf = BytesIO(b"\x00" * 255)
+
+        im = MockImageFile(buf)
+        im.tile = [("MOCK", None, 32, None)]
+
+        fp = BytesIO()
+        ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")])
+
+        assert self.encoder.state.xoff == 0
+        assert self.encoder.state.yoff == 0
+        assert self.encoder.state.xsize == 200
+        assert self.encoder.state.ysize == 200
+
+    def test_negsize(self):
+        buf = BytesIO(b"\x00" * 255)
+
+        im = MockImageFile(buf)
+
+        fp = BytesIO()
+        with pytest.raises(ValueError):
+            ImageFile._save(
+                im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")]
+            )
+
+        with pytest.raises(ValueError):
+            ImageFile._save(
+                im, fp, [("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")]
+            )
+
+    def test_oversize(self):
+        buf = BytesIO(b"\x00" * 255)
+
+        im = MockImageFile(buf)
+
+        fp = BytesIO()
+        with pytest.raises(ValueError):
+            ImageFile._save(
+                im,
+                fp,
+                [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB")],
+            )
+
+        with pytest.raises(ValueError):
+            ImageFile._save(
+                im,
+                fp,
+                [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB")],
+            )
+
+    def test_encode(self):
+        encoder = ImageFile.PyEncoder(None)
+        with pytest.raises(NotImplementedError):
+            encoder.encode(None)
+
+        bytes_consumed, errcode = encoder.encode_to_pyfd()
+        assert bytes_consumed == 0
+        assert ImageFile.ERRORS[errcode] == "bad configuration"
+
+        encoder._pushes_fd = True
+        with pytest.raises(NotImplementedError):
+            encoder.encode_to_pyfd()
+
+        with pytest.raises(NotImplementedError):
+            encoder.encode_to_file(None, None)