diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index c2f8d08cb83..c1fae44ca35 100644 --- 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 index 3e86477c526..f3da73e3810 100644 --- 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) diff --git a/docs/handbook/appendices.rst b/docs/handbook/appendices.rst index 6afaef07175..347a8848b37 100644 --- a/docs/handbook/appendices.rst +++ b/docs/handbook/appendices.rst @@ -8,4 +8,4 @@ Appendices image-file-formats text-anchors - writing-your-own-file-decoder + writing-your-own-image-plugin diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index d1a5ba33982..17808dbc463 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -26,6 +26,20 @@ Fully supported formats .. contents:: +BLP +^^^ + +BLP is the Blizzard Mipmap Format, a texture format used in World of +Warcraft. Pillow supports reading ``JPEG`` Compressed or raw ``BLP1`` +images, and all types of ``BLP2`` images. + +Pillow supports writing BLP images. The :py:meth:`~PIL.Image.Image.save` method +can take the following keyword arguments: + +**blp_version** + If present and set to "BLP1", images will be saved as BLP1. Otherwise, images + will be saved as BLP2. + BMP ^^^ @@ -1042,13 +1056,6 @@ Pillow reads and writes X bitmap files (mode ``1``). Read-only formats ----------------- -BLP -^^^ - -BLP is the Blizzard Mipmap Format, a texture format used in World of -Warcraft. Pillow supports reading ``JPEG`` Compressed or raw ``BLP1`` -images, and all types of ``BLP2`` images. - CUR ^^^ diff --git a/docs/handbook/writing-your-own-file-decoder.rst b/docs/handbook/writing-your-own-image-plugin.rst similarity index 93% rename from docs/handbook/writing-your-own-file-decoder.rst rename to docs/handbook/writing-your-own-image-plugin.rst index f69da9a9441..0c9cfe8e80d 100644 --- a/docs/handbook/writing-your-own-file-decoder.rst +++ b/docs/handbook/writing-your-own-image-plugin.rst @@ -4,10 +4,9 @@ Writing Your Own Image Plugin ============================= Pillow uses a plugin model which allows you to add your own -decoders to the library, without any changes to the library -itself. Such plugins usually have names like -:file:`XxxImagePlugin.py`, where ``Xxx`` is a unique format name -(usually an abbreviation). +decoders and encoders to the library, without any changes to the library +itself. Such plugins usually have names like :file:`XxxImagePlugin.py`, +where ``Xxx`` is a unique format name (usually an abbreviation). .. warning:: Pillow >= 2.1.0 no longer automatically imports any file in the Python path with a name ending in @@ -413,23 +412,24 @@ value, or if there is a read error from the file. This function should free any allocated memory and release any resources from external libraries. -.. _file-decoders-py: +.. _file-codecs-py: -Writing Your Own File Decoder in Python -======================================= +Writing Your Own File Codec in Python +===================================== -Python file decoders should derive from -:py:class:`PIL.ImageFile.PyDecoder` and should at least override the -decode method. File decoders should be registered using -:py:meth:`PIL.Image.register_decoder`. As in the C implementation of -the file decoders, there are three stages in the lifetime of a -Python-based file decoder: +Python file decoders and encoders should derive from +:py:class:`PIL.ImageFile.PyDecoder` and :py:class:`PIL.ImageFile.PyEncoder` +respectively, and should at least override the decode or encode method. +They should be registered using :py:meth:`PIL.Image.register_decoder` and +:py:meth:`PIL.Image.register_encoder`. As in the C implementation of +the file codecs, there are three stages in the lifetime of a +Python-based file codec: 1. Setup: Pillow looks for the decoder in the registry, then instantiates the class. -2. Decoding: The decoder instance's ``decode`` method is repeatedly - called with a buffer of data to be interpreted. - -3. Cleanup: The decoder instance's ``cleanup`` method is called. +2. Transforming: The instance's ``decode`` method is repeatedly called with + a buffer of data to be interpreted, or the ``encode`` method is repeatedly + called with the size of data to be output. +3. Cleanup: The instance's ``cleanup`` method is called. diff --git a/docs/reference/ImageFile.rst b/docs/reference/ImageFile.rst index e0ce389e862..3cf59c610c8 100644 --- a/docs/reference/ImageFile.rst +++ b/docs/reference/ImageFile.rst @@ -40,8 +40,16 @@ Classes .. autoclass:: PIL.ImageFile.Parser() :members: +.. autoclass:: PIL.ImageFile.PyCodec() + :members: + .. autoclass:: PIL.ImageFile.PyDecoder() :members: + :show-inheritance: + +.. autoclass:: PIL.ImageFile.PyEncoder() + :members: + :show-inheritance: .. autoclass:: PIL.ImageFile.ImageFile() :member-order: bysource diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 8fd2b851097..779fddea860 100644 --- 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("` - """ - - _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` + """ + + _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 diff --git a/src/encode.c b/src/encode.c index 2ecf9723bc6..a52d48f624a 100644 --- a/src/encode.c +++ b/src/encode.c @@ -149,14 +149,13 @@ _encode(ImagingEncoderObject *encoder, PyObject *args) { } static PyObject * -_encode_to_pyfd(ImagingEncoderObject *encoder, PyObject *args) { +_encode_to_pyfd(ImagingEncoderObject *encoder) { PyObject *result; int status; if (!encoder->pushes_fd) { // UNDONE, appropriate errcode??? result = Py_BuildValue("ii", 0, IMAGING_CODEC_CONFIG); - ; return result; } @@ -307,7 +306,7 @@ static struct PyMethodDef methods[] = { {"encode", (PyCFunction)_encode, METH_VARARGS}, {"cleanup", (PyCFunction)_encode_cleanup, METH_VARARGS}, {"encode_to_file", (PyCFunction)_encode_to_file, METH_VARARGS}, - {"encode_to_pyfd", (PyCFunction)_encode_to_pyfd, METH_VARARGS}, + {"encode_to_pyfd", (PyCFunction)_encode_to_pyfd, METH_NOARGS}, {"setimage", (PyCFunction)_setimage, METH_VARARGS}, {"setfd", (PyCFunction)_setfd, METH_VARARGS}, {NULL, NULL} /* sentinel */