diff --git a/CHANGES.rst b/CHANGES.rst index 63a96ec7f..eeb733283 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,30 @@ Changelog (Pillow) 9.3.0 (unreleased) ------------------ +- Don't reassign crc on ChunkStream close #6627 + [wiredfool, radarhere] + +- Raise a warning if NumPy failed to raise an error during conversion #6594 + [radarhere] + +- Show all frames in ImageShow #6611 + [radarhere] + +- Allow FLI palette chunk to not be first #6626 + [radarhere] + +- If first GIF frame has transparency for RGB_ALWAYS loading strategy, use RGBA mode #6592 + [radarhere] + +- Round box position to integer when pasting embedded color #6517 + [radarhere, nulano] + +- Removed EXIF prefix when saving WebP #6582 + [radarhere] + +- Pad IM palette to 768 bytes when saving #6579 + [radarhere] + - Added DDS BC6 reading #6449 [ShadelessFox, REDxEYE, radarhere] diff --git a/Tests/images/bw_gradient.imt b/Tests/images/bw_gradient.imt new file mode 100644 index 000000000..d765cf95f Binary files /dev/null and b/Tests/images/bw_gradient.imt differ diff --git a/Tests/images/hopper_palette_chunk_second.fli b/Tests/images/hopper_palette_chunk_second.fli new file mode 100644 index 000000000..54447de0a Binary files /dev/null and b/Tests/images/hopper_palette_chunk_second.fli differ diff --git a/Tests/images/text_float_coord.png b/Tests/images/text_float_coord.png new file mode 100644 index 000000000..49468698c Binary files /dev/null and b/Tests/images/text_float_coord.png differ diff --git a/Tests/images/text_float_coord_1_alt.png b/Tests/images/text_float_coord_1_alt.png new file mode 100644 index 000000000..50bdac3d8 Binary files /dev/null and b/Tests/images/text_float_coord_1_alt.png differ diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index a7d43d2e9..b8b999d70 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -4,7 +4,7 @@ import pytest from PIL import FliImagePlugin, Image -from .helper import assert_image_equal_tofile, is_pypy +from .helper import assert_image_equal, assert_image_equal_tofile, is_pypy # created as an export of a palette image from Gimp2.6 # save as...-> hopper.fli, default options. @@ -79,6 +79,12 @@ def test_invalid_file(): FliImagePlugin.FliImageFile(invalid_file) +def test_palette_chunk_second(): + with Image.open("Tests/images/hopper_palette_chunk_second.fli") as im: + with Image.open(static_test_file) as expected: + assert_image_equal(im.convert("RGB"), expected.convert("RGB")) + + def test_n_frames(): with Image.open(static_test_file) as im: assert im.n_frames == 1 diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 637be2be4..c612c573a 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -84,17 +84,24 @@ def test_l_mode_transparency(): def test_strategy(): + with Image.open("Tests/images/iss634.gif") as im: + expected_rgb_always = im.convert("RGB") + with Image.open("Tests/images/chi.gif") as im: - expected_zero = im.convert("RGB") + expected_rgb_always_rgba = im.convert("RGBA") im.seek(1) - expected_one = im.convert("RGB") + expected_different = im.convert("RGB") try: GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS - with Image.open("Tests/images/chi.gif") as im: + with Image.open("Tests/images/iss634.gif") as im: assert im.mode == "RGB" - assert_image_equal(im, expected_zero) + assert_image_equal(im, expected_rgb_always) + + with Image.open("Tests/images/chi.gif") as im: + assert im.mode == "RGBA" + assert_image_equal(im, expected_rgb_always_rgba) GifImagePlugin.LOADING_STRATEGY = ( GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY @@ -105,7 +112,7 @@ def test_strategy(): im.seek(1) assert im.mode == "P" - assert_image_equal(im.convert("RGB"), expected_one) + assert_image_equal(im.convert("RGB"), expected_different) # Change to RGB mode when a frame has an individual palette with Image.open("Tests/images/iss634.gif") as im: diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index e458a197c..5cf93713b 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -86,6 +86,18 @@ def test_roundtrip(mode, tmp_path): assert_image_equal_tofile(im, out) +def test_small_palette(tmp_path): + im = Image.new("P", (1, 1)) + colors = [0, 1, 2] + im.putpalette(colors) + + out = str(tmp_path / "temp.im") + im.save(out) + + with Image.open(out) as reloaded: + assert reloaded.getpalette() == colors + [0] * 765 + + def test_save_unsupported_mode(tmp_path): out = str(tmp_path / "temp.im") im = hopper("HSV") diff --git a/Tests/test_file_imt.py b/Tests/test_file_imt.py new file mode 100644 index 000000000..f56acc429 --- /dev/null +++ b/Tests/test_file_imt.py @@ -0,0 +1,19 @@ +import io + +import pytest + +from PIL import Image, ImtImagePlugin + +from .helper import assert_image_equal_tofile + + +def test_sanity(): + with Image.open("Tests/images/bw_gradient.imt") as im: + assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") + + +@pytest.mark.parametrize("data", (b"\n", b"\n-", b"width 1\n")) +def test_invalid_file(data): + with io.BytesIO(data) as fp: + with pytest.raises(SyntaxError): + ImtImagePlugin.ImtImageFile(fp) diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index e6d6fc63f..f77a245c0 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -55,9 +55,7 @@ def test_write_exif_metadata(): test_buffer.seek(0) with Image.open(test_buffer) as webp_image: webp_exif = webp_image.info.get("exif", None) - assert webp_exif - if webp_exif: - assert webp_exif == expected_exif, "WebP EXIF didn't match" + assert webp_exif == expected_exif[6:], "WebP EXIF didn't match" def test_read_icc_profile(): diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 7e5fd6fe1..ae3518e44 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -35,10 +35,13 @@ def test_toarray(): test_with_dtype(numpy.float64) test_with_dtype(numpy.uint8) - if parse_version(numpy.__version__) >= parse_version("1.23"): - with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated: + with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated: + if parse_version(numpy.__version__) >= parse_version("1.23"): with pytest.raises(OSError): numpy.array(im_truncated) + else: + with pytest.warns(UserWarning): + numpy.array(im_truncated) def test_fromarray(): diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 6cf5480af..f37f7e523 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -935,7 +935,30 @@ def test_standard_embedded_color(layout_engine): d = ImageDraw.Draw(im) d.text((10, 10), txt, font=ttf, fill="#fa6", embedded_color=True) - assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 6.2) + assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1) + + +@pytest.mark.parametrize("fontmode", ("1", "L", "RGBA")) +def test_float_coord(layout_engine, fontmode): + txt = "Hello World!" + ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) + + im = Image.new("RGB", (300, 64), "white") + d = ImageDraw.Draw(im) + if fontmode == "1": + d.fontmode = "1" + + embedded_color = fontmode == "RGBA" + d.text((9.5, 9.5), txt, font=ttf, fill="#fa6", embedded_color=embedded_color) + try: + assert_image_similar_tofile(im, "Tests/images/text_float_coord.png", 3.9) + except AssertionError: + if fontmode == "1" and layout_engine == ImageFont.Layout.BASIC: + assert_image_similar_tofile( + im, "Tests/images/text_float_coord_1_alt.png", 1 + ) + else: + raise def test_cbdt(layout_engine): diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index 39d91eade..fca5cffaf 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -6,10 +6,8 @@ from PIL import Image, ImageMath def pixel(im): if hasattr(im, "im"): return f"{im.mode} {repr(im.getpixel((0, 0)))}" - else: - if isinstance(im, int): - return int(im) # hack to deal with booleans - print(im) + elif isinstance(im, int): + return int(im) # hack to deal with booleans A = Image.new("L", (1, 1), 1) diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 66eeaf6f8..a9b33e437 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -60,7 +60,10 @@ Pillow also provides limited support for a few additional modes, including: * ``BGR;24`` (24-bit reversed true colour) * ``BGR;32`` (32-bit reversed true colour) -However, Pillow doesn’t support user-defined modes; if you need to handle band +Apart from these additional modes, Pillow doesn't yet support multichannel +images with a depth of more than 8 bits per channel. + +Pillow also doesn’t support user-defined modes; if you need to handle band combinations that are not listed above, use a sequence of Image objects. You can read the mode of an image through the :py:attr:`~PIL.Image.Image.mode` diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index e13b1779c..908bed9f4 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -15,6 +15,7 @@ # See the README file for information on usage and redistribution. # +import os from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -80,11 +81,19 @@ class FliImageFile(ImageFile.ImageFile): if i16(s, 4) == 0xF1FA: # look for palette chunk - s = self.fp.read(6) - if i16(s, 4) == 11: - self._palette(palette, 2) - elif i16(s, 4) == 4: - self._palette(palette, 0) + number_of_subchunks = i16(s, 6) + chunk_size = None + for _ in range(number_of_subchunks): + if chunk_size is not None: + self.fp.seek(chunk_size - 6, os.SEEK_CUR) + s = self.fp.read(6) + chunk_type = i16(s, 4) + if chunk_type in (4, 11): + self._palette(palette, 2 if chunk_type == 11 else 0) + break + chunk_size = i32(s) + if not chunk_size: + break palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] self.palette = ImagePalette.raw("RGB", b"".join(palette)) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 20435fe31..9df31e5ee 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -299,11 +299,13 @@ class GifImageFile(ImageFile.ImageFile): self.im.paste(self.dispose, self.dispose_extent) self._frame_palette = palette or self.global_palette + self._frame_transparency = frame_transparency if frame == 0: if self._frame_palette: - self.mode = ( - "RGB" if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS else "P" - ) + if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + self.mode = "RGBA" if frame_transparency is not None else "RGB" + else: + self.mode = "P" else: self.mode = "L" @@ -313,7 +315,6 @@ class GifImageFile(ImageFile.ImageFile): palette = copy(self.global_palette) self.palette = palette else: - self._frame_transparency = frame_transparency if self.mode == "P": if ( LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY @@ -386,7 +387,8 @@ class GifImageFile(ImageFile.ImageFile): transparency = -1 if frame_transparency is not None: if frame == 0: - self.info["transparency"] = frame_transparency + if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS: + self.info["transparency"] = frame_transparency elif self.mode not in ("RGB", "RGBA"): transparency = frame_transparency self.tile = [ @@ -410,9 +412,9 @@ class GifImageFile(ImageFile.ImageFile): temp_mode = "P" if self._frame_palette else "L" self._prev_im = None if self.__frame == 0: - if "transparency" in self.info: + if self._frame_transparency is not None: self.im = Image.core.fill( - temp_mode, self.size, self.info["transparency"] + temp_mode, self.size, self._frame_transparency ) elif self.mode in ("RGB", "RGBA"): self._prev_im = self.im @@ -429,8 +431,12 @@ class GifImageFile(ImageFile.ImageFile): def load_end(self): if self.__frame == 0: if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: - self.mode = "RGB" - self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + self.mode = "RGBA" + else: + self.mode = "RGB" + self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) return if self.mode == "P" and self._prev_im: if self._frame_transparency is not None: diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 78ccfb9cf..31b0ff469 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -352,7 +352,13 @@ def _save(im, fp, filename): fp.write(b"Lut: 1\r\n") fp.write(b"\000" * (511 - fp.tell()) + b"\032") if im.mode in ["P", "PA"]: - fp.write(im.im.getpalette("RGB", "RGB;L")) # 768 bytes + im_palette = im.im.getpalette("RGB", "RGB;L") + colors = len(im_palette) // 3 + palette = b"" + for i in range(3): + palette += im_palette[colors * i : colors * (i + 1)] + palette += b"\x00" * (256 - colors) + fp.write(palette) # 768 bytes ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6611ceb3c..d5080a05c 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -679,12 +679,24 @@ class Image: new["shape"] = shape new["typestr"] = typestr new["version"] = 3 - if self.mode == "1": - # Binary images need to be extended from bits to bytes - # See: https://github.com/python-pillow/Pillow/issues/350 - new["data"] = self.tobytes("raw", "L") - else: - new["data"] = self.tobytes() + try: + if self.mode == "1": + # Binary images need to be extended from bits to bytes + # See: https://github.com/python-pillow/Pillow/issues/350 + new["data"] = self.tobytes("raw", "L") + else: + new["data"] = self.tobytes() + except Exception as e: + if not isinstance(e, (MemoryError, RecursionError)): + try: + import numpy + from packaging.version import parse as parse_version + except ImportError: + pass + else: + if parse_version(numpy.__version__) < parse_version("1.23"): + warnings.warn(e) + raise return new def __getstate__(self): diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e84dafb12..3a6063a6c 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -482,8 +482,8 @@ class ImageDraw: # extract mask and set text alpha color, mask = mask, mask.getband(3) color.fillband(3, (ink >> 24) & 0xFF) - coord2 = coord[0] + mask.size[0], coord[1] + mask.size[1] - self.im.paste(color, coord + coord2, mask) + x, y = (int(c) for c in coord) + self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) else: self.draw.draw_bitmap(coord, mask, ink) diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index 9f9a551fb..76f42a307 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -136,7 +136,7 @@ class WindowsViewer(Viewer): """The default viewer on Windows is the default system application for PNG files.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} def get_command(self, file, **options): return ( @@ -154,7 +154,7 @@ class MacViewer(Viewer): """The default viewer on macOS using ``Preview.app``.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} def get_command(self, file, **options): # on darwin open returns immediately resulting in the temp @@ -197,7 +197,7 @@ if sys.platform == "darwin": class UnixViewer(Viewer): format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} def get_command(self, file, **options): command = self.get_command_ex(file, **options)[0] diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index 5790acdaf..dc7078012 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -39,15 +39,19 @@ class ImtImageFile(ImageFile.ImageFile): # Quick rejection: if there's not a LF among the first # 100 bytes, this is (probably) not a text header. - if b"\n" not in self.fp.read(100): + buffer = self.fp.read(100) + if b"\n" not in buffer: raise SyntaxError("not an IM file") - self.fp.seek(0) xsize = ysize = 0 while True: - s = self.fp.read(1) + if buffer: + s = buffer[:1] + buffer = buffer[1:] + else: + s = self.fp.read(1) if not s: break @@ -55,7 +59,12 @@ class ImtImageFile(ImageFile.ImageFile): # image data begins self.tile = [ - ("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1)) + ( + "raw", + (0, 0) + self.size, + self.fp.tell() - len(buffer), + (self.mode, 0, 1), + ) ] break @@ -63,8 +72,11 @@ class ImtImageFile(ImageFile.ImageFile): else: # read key/value pair - # FIXME: dangerous, may read whole file - s = s + self.fp.readline() + if b"\n" not in buffer: + buffer += self.fp.read(100) + lines = buffer.split(b"\n") + s += lines.pop(0) + buffer = b"\n".join(lines) if len(s) == 1 or len(s) > 100: break if s[0] == ord(b"*"): @@ -74,13 +86,13 @@ class ImtImageFile(ImageFile.ImageFile): if not m: break k, v = m.group(1, 2) - if k == "width": + if k == b"width": xsize = int(v) self._size = xsize, ysize - elif k == "height": + elif k == b"height": ysize = int(v) self._size = xsize, ysize - elif k == "pixel" and v == "n8": + elif k == b"pixel" and v == b"n8": self.mode = "L" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 442c65e6f..abcffe926 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -189,7 +189,7 @@ class ChunkStream: self.close() def close(self): - self.queue = self.crc = self.fp = None + self.queue = self.fp = None def push(self, cid, pos, length): @@ -224,7 +224,7 @@ class ChunkStream: ) from e def crc_skip(self, cid, data): - """Read checksum. Used if the C module is not present""" + """Read checksum""" self.fp.read(4) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index c1f4b730f..5eaeb10cc 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -311,9 +311,11 @@ def _save(im, fp, filename): lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) icc_profile = im.encoderinfo.get("icc_profile") or "" - exif = im.encoderinfo.get("exif", "") + exif = im.encoderinfo.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] xmp = im.encoderinfo.get("xmp", "") method = im.encoderinfo.get("method", 4) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0da7d00f0..b6326ef69 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -138,9 +138,9 @@ deps = { "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/zlib1212.zip", - "filename": "zlib1212.zip", - "dir": "zlib-1.2.12", + "url": "https://zlib.net/zlib1213.zip", + "filename": "zlib1213.zip", + "dir": "zlib-1.2.13", "license": "README", "license_pattern": "Copyright notice:\n\n(.+)$", "build": [