From 06e02cc1d98dbcfb09839da080ee8eb318baa4ba Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 31 Dec 2024 20:48:55 +1100 Subject: [PATCH 01/32] Added compile-time mozjpeg feature flag --- src/PIL/features.py | 4 +++- src/_imaging.c | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/PIL/features.py b/src/PIL/features.py index 3645e3defc4..ae7ea4255ef 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -127,6 +127,7 @@ def get_supported_codecs() -> list[str]: "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), "libjpeg_turbo": ("PIL._imaging", "HAVE_LIBJPEGTURBO", "libjpeg_turbo_version"), + "mozjpeg": ("PIL._imaging", "HAVE_MOZJPEG", "libjpeg_turbo_version"), "zlib_ng": ("PIL._imaging", "HAVE_ZLIBNG", "zlib_ng_version"), "libimagequant": ("PIL._imaging", "HAVE_LIBIMAGEQUANT", "imagequant_version"), "xcb": ("PIL._imaging", "HAVE_XCB", None), @@ -300,7 +301,8 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: if name == "jpg": libjpeg_turbo_version = version_feature("libjpeg_turbo") if libjpeg_turbo_version is not None: - v = "libjpeg-turbo " + libjpeg_turbo_version + v = "mozjpeg" if check_feature("mozjpeg") else "libjpeg-turbo" + v += " " + libjpeg_turbo_version if v is None: v = version(name) if v is not None: diff --git a/src/_imaging.c b/src/_imaging.c index 5d6d97bedab..00772d01275 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -76,6 +76,13 @@ #ifdef HAVE_LIBJPEG #include "jconfig.h" +#ifdef LIBJPEG_TURBO_VERSION +#define JCONFIG_INCLUDED +#ifdef __CYGWIN__ +#define _BASETSD_H +#endif +#include "jpeglib.h" +#endif #endif #ifdef HAVE_LIBZ @@ -4367,6 +4374,15 @@ setup_module(PyObject *m) { Py_INCREF(have_libjpegturbo); PyModule_AddObject(m, "HAVE_LIBJPEGTURBO", have_libjpegturbo); + PyObject *have_mozjpeg; +#ifdef JPEG_C_PARAM_SUPPORTED + have_mozjpeg = Py_True; +#else + have_mozjpeg = Py_False; +#endif + Py_INCREF(have_mozjpeg); + PyModule_AddObject(m, "HAVE_MOZJPEG", have_mozjpeg); + PyObject *have_libimagequant; #ifdef HAVE_LIBIMAGEQUANT have_libimagequant = Py_True; From ae59b039564eefdebec4ea67a712a115d0e1ab67 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 31 Dec 2024 21:40:12 +1100 Subject: [PATCH 02/32] Do not use MozJPEG progressive default --- Tests/test_file_jpeg.py | 13 ++++++++++--- src/libImaging/JpegEncode.c | 11 ++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index bf0dec4b80e..d4c0636c668 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -281,7 +281,10 @@ def test_progressive(self) -> None: assert not im2.info.get("progressive") assert im3.info.get("progressive") - assert_image_equal(im1, im3) + if features.check_feature("mozjpeg"): + assert_image_similar(im1, im3, 9.39) + else: + assert_image_equal(im1, im3) assert im1_bytes >= im3_bytes def test_progressive_large_buffer(self, tmp_path: Path) -> None: @@ -424,8 +427,12 @@ def test_progressive_compat(self) -> None: im2 = self.roundtrip(hopper(), progressive=1) im3 = self.roundtrip(hopper(), progression=1) # compatibility - assert_image_equal(im1, im2) - assert_image_equal(im1, im3) + if features.check_feature("mozjpeg"): + assert_image_similar(im1, im2, 9.39) + assert_image_similar(im1, im3, 9.39) + else: + assert_image_equal(im1, im2) + assert_image_equal(im1, im3) assert im2.info.get("progressive") assert im2.info.get("progression") assert im3.info.get("progressive") diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 4372d51d5c3..3c11eac2206 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -134,7 +134,16 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { return -1; } - /* Compressor configuration */ + /* Compressor configuration */ +#ifdef JPEG_C_PARAM_SUPPORTED + /* MozJPEG */ + if (!context->progressive) { + /* Do not use MozJPEG progressive default */ + jpeg_c_set_int_param( + &context->cinfo, JINT_COMPRESS_PROFILE, JCP_FASTEST + ); + } +#endif jpeg_set_defaults(&context->cinfo); /* Prevent RGB -> YCbCr conversion */ From d626e6ab9f37c6bc27036982e282d42012ed0cab Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Jan 2025 09:07:41 +1100 Subject: [PATCH 03/32] text is a property --- Tests/test_file_png.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 974e1e75faa..d87883279a3 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -618,7 +618,7 @@ def test_textual_chunks_after_idat(self) -> None: with Image.open("Tests/images/truncated_image.png") as im: # The file is truncated with pytest.raises(OSError): - im.text() + im.text ImageFile.LOAD_TRUNCATED_IMAGES = True assert isinstance(im.text, dict) ImageFile.LOAD_TRUNCATED_IMAGES = False From 8d78cfcc5a798c59a193b80e200d9845992326ab Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Jan 2025 09:10:16 +1100 Subject: [PATCH 04/32] Added return types --- Tests/test_file_jpeg.py | 2 +- src/PIL/TiffImagePlugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index bf0dec4b80e..52fc9239c78 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -181,7 +181,7 @@ def test(xdpi: int, ydpi: int | None = None) -> tuple[int, int] | None: assert test(100, 200) == (100, 200) assert test(0) is None # square pixels - def test_dpi_jfif_cm(self): + def test_dpi_jfif_cm(self) -> None: with Image.open("Tests/images/jfif_unit_cm.jpg") as im: assert im.info["dpi"] == (2.54, 5.08) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 61eb1524311..93ad8903244 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -949,7 +949,7 @@ def load(self, fp: IO[bytes]) -> None: warnings.warn(str(msg)) return - def _get_ifh(self): + def _get_ifh(self) -> bytes: ifh = self._prefix + self._pack("H", 43 if self._bigtiff else 42) if self._bigtiff: ifh += self._pack("HH", 8, 0) From beda2b6e8d20050a16fbc261753fd30e410fba93 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Jan 2025 10:49:24 +1100 Subject: [PATCH 05/32] Removed unused image open --- Tests/test_file_ico.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 37770498a0a..e81aae66995 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -253,8 +253,7 @@ def test_truncated_mask() -> None: try: with Image.open(io.BytesIO(data)) as im: - with Image.open("Tests/images/hopper_mask.png") as expected: - assert im.mode == "1" + assert im.mode == "1" # 32 bpp output = io.BytesIO() From b89cc09944b4add584967bf1fa21208e92442def Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Jan 2025 12:22:55 +1100 Subject: [PATCH 06/32] Corrected BLP1 alpha depth handling --- Tests/test_file_blp.py | 1 + src/PIL/BlpImagePlugin.py | 43 ++++++++++++++++++++++++--------------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 1e2f20c407b..1f32be9c134 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -19,6 +19,7 @@ def test_load_blp1() -> None: assert_image_equal_tofile(im, "Tests/images/blp/blp1_jpeg.png") with Image.open("Tests/images/blp/blp1_jpeg2.blp") as im: + assert im.mode == "RGBA" im.load() diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 2d03af9d7dd..0d882fe9686 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -260,18 +260,21 @@ class BlpImageFile(ImageFile.ImageFile): def _open(self) -> None: self.magic = self.fp.read(4) - self.fp.seek(5, os.SEEK_CUR) - (self._blp_alpha_depth,) = struct.unpack(" None: self.fd.seek(4) (self._blp_compression,) = struct.unpack(" None: assert im.palette is not None fp.write(struct.pack(" Date: Wed, 1 Jan 2025 22:58:04 +1100 Subject: [PATCH 07/32] Do not reread start of header in decoder --- src/PIL/BlpImagePlugin.py | 125 +++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 64 deletions(-) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 0d882fe9686..c932b3b9c8a 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -259,24 +259,36 @@ class BlpImageFile(ImageFile.ImageFile): def _open(self) -> None: self.magic = self.fp.read(4) + if not _accept(self.magic): + msg = f"Bad BLP magic {repr(self.magic)}" + raise BLPFormatError(msg) + compression = struct.unpack(" tuple[int, int]: try: - self._read_blp_header() + self._read_header() self._load() except struct.error as e: msg = "Truncated BLP file" @@ -295,28 +307,9 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int def _load(self) -> None: pass - def _read_blp_header(self) -> None: - assert self.fd is not None - self.fd.seek(4) - (self._blp_compression,) = struct.unpack(" None: + self._offsets = struct.unpack("<16I", self._safe_read(16 * 4)) + self._lengths = struct.unpack("<16I", self._safe_read(16 * 4)) def _safe_read(self, length: int) -> bytes: assert self.fd is not None @@ -332,9 +325,11 @@ def _read_palette(self) -> list[tuple[int, int, int, int]]: ret.append((b, g, r, a)) return ret - def _read_bgra(self, palette: list[tuple[int, int, int, int]]) -> bytearray: + def _read_bgra( + self, palette: list[tuple[int, int, int, int]], alpha: bool + ) -> bytearray: data = bytearray() - _data = BytesIO(self._safe_read(self._blp_lengths[0])) + _data = BytesIO(self._safe_read(self._lengths[0])) while True: try: (offset,) = struct.unpack(" bytearray: break b, g, r, a = palette[offset] d: tuple[int, ...] = (r, g, b) - if self._blp_alpha_depth: + if alpha: d += (a,) data.extend(d) return data @@ -350,19 +345,21 @@ def _read_bgra(self, palette: list[tuple[int, int, int, int]]) -> bytearray: class BLP1Decoder(_BLPBaseDecoder): def _load(self) -> None: - if self._blp_compression == Format.JPEG: + self._compression, self._encoding, alpha = self.args + + if self._compression == Format.JPEG: self._decode_jpeg_stream() - elif self._blp_compression == 1: - if self._blp_encoding in (4, 5): + elif self._compression == 1: + if self._encoding in (4, 5): palette = self._read_palette() - data = self._read_bgra(palette) + data = self._read_bgra(palette, alpha) self.set_as_raw(data) else: - msg = f"Unsupported BLP encoding {repr(self._blp_encoding)}" + msg = f"Unsupported BLP encoding {repr(self._encoding)}" raise BLPFormatError(msg) else: - msg = f"Unsupported BLP compression {repr(self._blp_encoding)}" + msg = f"Unsupported BLP compression {repr(self._encoding)}" raise BLPFormatError(msg) def _decode_jpeg_stream(self) -> None: @@ -371,8 +368,8 @@ def _decode_jpeg_stream(self) -> None: (jpeg_header_size,) = struct.unpack(" None: class BLP2Decoder(_BLPBaseDecoder): def _load(self) -> None: + self._compression, self._encoding, alpha, self._alpha_encoding = self.args + palette = self._read_palette() assert self.fd is not None - self.fd.seek(self._blp_offsets[0]) + self.fd.seek(self._offsets[0]) - if self._blp_compression == 1: + if self._compression == 1: # Uncompressed or DirectX compression - if self._blp_encoding == Encoding.UNCOMPRESSED: - data = self._read_bgra(palette) + if self._encoding == Encoding.UNCOMPRESSED: + data = self._read_bgra(palette, alpha) - elif self._blp_encoding == Encoding.DXT: + elif self._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): - for d in decode_dxt1( - self._safe_read(linesize), alpha=bool(self._blp_alpha_depth) - ): + if self._alpha_encoding == AlphaEncoding.DXT1: + linesize = (self.state.xsize + 3) // 4 * 8 + for yb in range((self.state.ysize + 3) // 4): + for d in decode_dxt1(self._safe_read(linesize), alpha): data += d - elif self._blp_alpha_encoding == AlphaEncoding.DXT3: - linesize = (self.size[0] + 3) // 4 * 16 - for yb in range((self.size[1] + 3) // 4): + elif self._alpha_encoding == AlphaEncoding.DXT3: + linesize = (self.state.xsize + 3) // 4 * 16 + for yb in range((self.state.ysize + 3) // 4): for d in decode_dxt3(self._safe_read(linesize)): data += d - elif self._blp_alpha_encoding == AlphaEncoding.DXT5: - linesize = (self.size[0] + 3) // 4 * 16 - for yb in range((self.size[1] + 3) // 4): + elif self._alpha_encoding == AlphaEncoding.DXT5: + linesize = (self.state.xsize + 3) // 4 * 16 + for yb in range((self.state.ysize + 3) // 4): for d in decode_dxt5(self._safe_read(linesize)): data += d else: - msg = f"Unsupported alpha encoding {repr(self._blp_alpha_encoding)}" + msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}" raise BLPFormatError(msg) else: - msg = f"Unknown BLP encoding {repr(self._blp_encoding)}" + msg = f"Unknown BLP encoding {repr(self._encoding)}" raise BLPFormatError(msg) else: - msg = f"Unknown BLP compression {repr(self._blp_compression)}" + msg = f"Unknown BLP compression {repr(self._compression)}" raise BLPFormatError(msg) self.set_as_raw(data) From 5d998d3fedb06666ae680e3ebe3f3547a9059727 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Jan 2025 23:38:24 +1100 Subject: [PATCH 08/32] Improved coverage --- Tests/test_file_blp.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 1f32be9c134..9f2de8f982e 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -4,7 +4,7 @@ import pytest -from PIL import Image +from PIL import BlpImagePlugin, Image from .helper import ( assert_image_equal, @@ -38,6 +38,13 @@ def test_load_blp2_dxt1a() -> None: assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1a.png") +def test_invalid_file() -> None: + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(BlpImagePlugin.BLPFormatError): + BlpImagePlugin.BlpImageFile(invalid_file) + + def test_save(tmp_path: Path) -> None: f = str(tmp_path / "temp.blp") From f636cb8c156f53cb3acd3ebf7164113850df3f27 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 2 Jan 2025 10:28:51 +1100 Subject: [PATCH 09/32] Updated freetype to 2.13.3 --- .github/workflows/wheels-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 4e0fad79f4e..9059c04a4f5 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -37,7 +37,7 @@ fi ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds -FREETYPE_VERSION=2.13.2 +FREETYPE_VERSION=2.13.3 HARFBUZZ_VERSION=10.1.0 LIBPNG_VERSION=1.6.44 JPEGTURBO_VERSION=3.1.0 From 4c1aed801e43c6b307e7135279ca1dbc02bbf052 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 2 Jan 2025 16:00:59 +1100 Subject: [PATCH 10/32] 11.1.0 version bump --- src/PIL/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 0807f949c31..9938a0afcf6 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "11.1.0.dev0" +__version__ = "11.1.0" From 57786a252b2e3abd63242800ab06511bb315b2d8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 2 Jan 2025 19:04:18 +1100 Subject: [PATCH 11/32] 11.2.0.dev0 version bump --- src/PIL/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 9938a0afcf6..e93c7887b80 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "11.1.0" +__version__ = "11.2.0.dev0" From 6b4619c4f5998d8d40de32de7b17b664d9b8a0db Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 2 Jan 2025 20:46:58 +1100 Subject: [PATCH 12/32] Updated macOS tested Pillow versions --- docs/installation/platform-support.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 3741c595602..7561946792f 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -77,7 +77,7 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+============================+==================+==============+ -| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.0.0 |arm | +| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.1.0 |arm | | +----------------------------+------------------+ | | | 3.8 | 10.4.0 | | +----------------------------------+----------------------------+------------------+--------------+ From ade15fcdd3c9f41606ce560c4b5fdeb01f0025e2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 2 Jan 2025 12:46:24 +0200 Subject: [PATCH 13/32] Upgrade zlib-ng to 2.2.3 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 4e0fad79f4e..e89db5020f8 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -50,7 +50,7 @@ if [[ -n "$IS_MACOS" ]]; then else GIFLIB_VERSION=5.2.1 fi -ZLIB_NG_VERSION=2.2.2 +ZLIB_NG_VERSION=2.2.3 LIBWEBP_VERSION=1.5.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0674a9a1528..75d6aa1bd2d 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -121,7 +121,7 @@ def cmd_msbuild( "OPENJPEG": "2.5.3", "TIFF": "4.6.0", "XZ": "5.6.3", - "ZLIBNG": "2.2.2", + "ZLIBNG": "2.2.3", } V["LIBPNG_DOTLESS"] = V["LIBPNG"].replace(".", "") V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) From 2d7597ac6a431d283a65d1d17622a6d8f9918010 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 2 Jan 2025 22:50:25 +1100 Subject: [PATCH 14/32] Updated to giflib 5.2.2 on Linux --- .github/workflows/wheels-dependencies.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 4e0fad79f4e..71609a6f49a 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -45,11 +45,7 @@ OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.6.3 TIFF_VERSION=4.6.0 LCMS2_VERSION=2.16 -if [[ -n "$IS_MACOS" ]]; then - GIFLIB_VERSION=5.2.2 -else - GIFLIB_VERSION=5.2.1 -fi +GIFLIB_VERSION=5.2.2 ZLIB_NG_VERSION=2.2.2 LIBWEBP_VERSION=1.5.0 BZIP2_VERSION=1.0.8 @@ -139,6 +135,14 @@ function build { CFLAGS="$CFLAGS -O3 -DNDEBUG" if [[ -n "$IS_MACOS" ]]; then CFLAGS="$CFLAGS -Wl,-headerpad_max_install_names" + # For giflib 5.2.2 + elif [ -n "$IS_ALPINE" ]; then + apk add imagemagick + else + if [[ "$MB_ML_VER" == "_2_28" ]]; then + yum install -y epel-release + fi + yum install -y ImageMagick fi build_libwebp CFLAGS=$ORIGINAL_CFLAGS From 1678f7f2155beafa594c3561179f4069f9318d35 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 2 Jan 2025 17:38:21 +0100 Subject: [PATCH 15/32] Add overloads for exif_transpose --- src/PIL/ImageOps.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index bb29cc0d3e8..fef1d7328c2 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -22,7 +22,7 @@ import operator import re from collections.abc import Sequence -from typing import Protocol, cast +from typing import Literal, Protocol, cast, overload from . import ExifTags, Image, ImagePalette @@ -673,6 +673,16 @@ def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: return _lut(image, lut) +@overload +def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ... + + +@overload +def exif_transpose( + image: Image.Image, *, in_place: Literal[False] = False +) -> Image.Image: ... + + def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: """ If an image has an EXIF Orientation tag, other than 1, transpose the image From 1d771ff4a40e8eb9a38c150d18767cddf01c8a47 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Jan 2025 10:26:47 +1100 Subject: [PATCH 16/32] Do not call yum on cifuzz --- .github/workflows/wheels-dependencies.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 1ebc49a88e0..ceb7911be5a 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -99,7 +99,7 @@ function build_harfbuzz { function build { build_xz - if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then + if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then yum remove -y zlib-devel fi build_zlib_ng @@ -138,6 +138,8 @@ function build { # For giflib 5.2.2 elif [ -n "$IS_ALPINE" ]; then apk add imagemagick + elif [ -n "$SANITIZER" ]; then + apt-get install -y imagemagick else if [[ "$MB_ML_VER" == "_2_28" ]]; then yum install -y epel-release From d12e78badf1fc4a102b4bec044eb12a6bfd5d0aa Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:00:19 +1100 Subject: [PATCH 17/32] Removed exif_transpose return type checks --- Tests/test_file_jpeg.py | 1 - Tests/test_imageops.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index bf0dec4b80e..dd62460bb5d 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -353,7 +353,6 @@ def test_empty_exif_gps(self) -> None: assert exif.get_ifd(0x8825) == {} transposed = ImageOps.exif_transpose(im) - assert transposed is not None exif = transposed.getexif() assert exif.get_ifd(0x8825) == {} diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 2fb2a60b632..7262f29e64a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -405,7 +405,6 @@ def check(orientation_im: Image.Image) -> None: else: original_exif = im.info["exif"] transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert_image_similar(base_im, transposed_im, 17) if orientation_im is base_im: assert "exif" not in im.info @@ -417,7 +416,6 @@ def check(orientation_im: Image.Image) -> None: # Repeat the operation to test that it does not keep transposing transposed_im2 = ImageOps.exif_transpose(transposed_im) - assert transposed_im2 is not None assert_image_equal(transposed_im2, transposed_im) check(base_im) @@ -433,7 +431,6 @@ def check(orientation_im: Image.Image) -> None: assert im.getexif()[0x0112] == 3 transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() transposed_im._reload_exif() @@ -446,14 +443,12 @@ def check(orientation_im: Image.Image) -> None: assert im.getexif()[0x0112] == 3 transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() # Orientation set directly on Image.Exif im = hopper() im.getexif()[0x0112] = 3 transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() @@ -464,7 +459,6 @@ def test_exif_transpose_xml_without_xmp() -> None: del im.info["xmp"] transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() From 036db2da87dba7283207b8b61cf9ca131d1223a3 Mon Sep 17 00:00:00 2001 From: "Harm.van.den.brand@alliander.com" Date: Thu, 2 Jan 2025 16:47:24 +0100 Subject: [PATCH 18/32] OSError caused by decode error should use string argument to be in line with rest of module --- Tests/test_file_libtiff.py | 2 +- src/PIL/TiffImagePlugin.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 9c49b1534ed..49d71aca77f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1146,7 +1146,7 @@ def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None: im.load() # Assert that the error code is IMAGING_CODEC_MEMORY - assert str(e.value) == "-9" + assert str(e.value) == "decoder error -9" @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 61eb1524311..bbbd656c68a 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1406,7 +1406,8 @@ def _load_libtiff(self) -> Image.core.PixelAccess | None: self.fp = None # might be shared if err < 0: - raise OSError(err) + msg = f"decoder error {err}" + raise OSError(msg) return Image.Image.load(self) From cce0f5b653abcdf99a7bbba6757f000b3fc4cd7e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 4 Jan 2025 10:34:59 +1100 Subject: [PATCH 19/32] Removed giflib as webp dependency --- .github/workflows/wheels-dependencies.sh | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 1ebc49a88e0..05167d96962 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -45,7 +45,6 @@ OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.6.3 TIFF_VERSION=4.6.0 LCMS2_VERSION=2.16 -GIFLIB_VERSION=5.2.2 ZLIB_NG_VERSION=2.2.3 LIBWEBP_VERSION=1.5.0 BZIP2_VERSION=1.0.8 @@ -135,16 +134,10 @@ function build { CFLAGS="$CFLAGS -O3 -DNDEBUG" if [[ -n "$IS_MACOS" ]]; then CFLAGS="$CFLAGS -Wl,-headerpad_max_install_names" - # For giflib 5.2.2 - elif [ -n "$IS_ALPINE" ]; then - apk add imagemagick - else - if [[ "$MB_ML_VER" == "_2_28" ]]; then - yum install -y epel-release - fi - yum install -y ImageMagick fi - build_libwebp + build_simple libwebp $LIBWEBP_VERSION \ + https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \ + --enable-libwebpmux --enable-libwebpdemux CFLAGS=$ORIGINAL_CFLAGS build_brotli From bd56a956594445c9b2e0bd5004f1b5c1a3f96b38 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Jan 2025 12:43:50 +1100 Subject: [PATCH 20/32] Use namedtuple _replace --- src/PIL/BlpImagePlugin.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index c932b3b9c8a..b8a95db879d 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -374,11 +374,9 @@ def _decode_jpeg_stream(self) -> None: image = JpegImageFile(BytesIO(data)) Image._decompression_bomb_check(image.size) if image.mode == "CMYK": - decoder_name, extents, offset, args = image.tile[0] + args = image.tile[0].args assert isinstance(args, tuple) - image.tile = [ - ImageFile._Tile(decoder_name, extents, offset, (args[0], "CMYK")) - ] + image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))] r, g, b = image.convert("RGB").split() reversed_image = Image.merge("RGB", (b, g, r)) self.set_as_raw(reversed_image.tobytes()) From 73a383fa7211adf5ed8ffa43288e6bc47daa125e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 5 Jan 2025 06:11:54 +1100 Subject: [PATCH 21/32] Use rawmode instead of splitting and merging --- src/PIL/BlpImagePlugin.py | 4 +--- src/libImaging/Unpack.c | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index b8a95db879d..8585a8e60fd 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -377,9 +377,7 @@ def _decode_jpeg_stream(self) -> None: args = image.tile[0].args assert isinstance(args, tuple) image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))] - r, g, b = image.convert("RGB").split() - reversed_image = Image.merge("RGB", (b, g, r)) - self.set_as_raw(reversed_image.tobytes()) + self.set_as_raw(image.convert("RGB").tobytes(), "BGR") class BLP2Decoder(_BLPBaseDecoder): diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index e9203fe4d74..9c3ee26655f 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -1664,6 +1664,7 @@ static struct { {"RGBA", "RGBaXX", 48, unpackRGBaskip2}, {"RGBA", "RGBa;16L", 64, unpackRGBa16L}, {"RGBA", "RGBa;16B", 64, unpackRGBa16B}, + {"RGBA", "BGR", 24, ImagingUnpackBGR}, {"RGBA", "BGRa", 32, unpackBGRa}, {"RGBA", "RGBA;I", 32, unpackRGBAI}, {"RGBA", "RGBA;L", 32, unpackRGBAL}, From 4ecf8cbd75051c7213a433f80b6a9f24e4367311 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 5 Jan 2025 14:49:34 +1100 Subject: [PATCH 22/32] Simplified code --- src/_imagingft.c | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index d38279f3e4b..3a65007a514 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -339,29 +339,23 @@ text_layout_raqm( len = PySequence_Fast_GET_SIZE(seq); for (j = 0; j < len; j++) { PyObject *item = PySequence_Fast_GET_ITEM(seq, j); - char *feature = NULL; - Py_ssize_t size = 0; - PyObject *bytes; - if (!PyUnicode_Check(item)) { Py_DECREF(seq); PyErr_SetString(PyExc_TypeError, "expected a string"); goto failed; } - bytes = PyUnicode_AsUTF8String(item); - if (bytes == NULL) { + + Py_ssize_t size; + const char *feature = PyUnicode_AsUTF8AndSize(item, &size); + if (feature == NULL) { Py_DECREF(seq); goto failed; } - feature = PyBytes_AS_STRING(bytes); - size = PyBytes_GET_SIZE(bytes); if (!raqm_add_font_feature(rq, feature, size)) { Py_DECREF(seq); - Py_DECREF(bytes); PyErr_SetString(PyExc_ValueError, "raqm_add_font_feature() failed"); goto failed; } - Py_DECREF(bytes); } Py_DECREF(seq); } From 7708e4b524aca2e7d56fcc75eee59834622f75e8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 6 Jan 2025 20:30:47 +1100 Subject: [PATCH 23/32] Improved Docker coverage reporting --- .ci/after_success.sh | 6 +----- .github/workflows/test-docker.yml | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.ci/after_success.sh b/.ci/after_success.sh index c71546f007b..6da27b975cc 100755 --- a/.ci/after_success.sh +++ b/.ci/after_success.sh @@ -2,8 +2,4 @@ # gather the coverage data python3 -m pip install coverage -if [[ $MATRIX_DOCKER ]]; then - python3 -m coverage xml --ignore-errors -else - python3 -m coverage xml -fi +python3 -m coverage xml diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 4b01a10e4c2..bebb9cda277 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -90,15 +90,15 @@ jobs: - name: After success run: | - PATH="$PATH:~/.local/bin" docker start pillow_container + sudo docker cp pillow_container:/Pillow /Pillow + sudo chown -R runner /Pillow pil_path=`docker exec pillow_container /vpy3/bin/python -c 'import os, PIL;print(os.path.realpath(os.path.dirname(PIL.__file__)))'` docker stop pillow_container sudo mkdir -p $pil_path sudo cp src/PIL/*.py $pil_path + cd /Pillow .ci/after_success.sh - env: - MATRIX_DOCKER: ${{ matrix.docker }} - name: Upload coverage uses: codecov/codecov-action@v5 From b1749dff08ab96a05234e1492759011ef54cbd59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:35:41 +0000 Subject: [PATCH 24/32] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.8.4 → v0.8.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.4...v0.8.6) - [github.com/pre-commit/mirrors-clang-format: v19.1.5 → v19.1.6](https://github.com/pre-commit/mirrors-clang-format/compare/v19.1.5...v19.1.6) - [github.com/woodruffw/zizmor-pre-commit: v0.10.0 → v1.0.0](https://github.com/woodruffw/zizmor-pre-commit/compare/v0.10.0...v1.0.0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b76f92ec00e..20fa7d04f00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 + rev: v0.8.6 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v19.1.5 + rev: v19.1.6 hooks: - id: clang-format types: [c] @@ -57,7 +57,7 @@ repos: - id: check-renovate - repo: https://github.com/woodruffw/zizmor-pre-commit - rev: v0.10.0 + rev: v1.0.0 hooks: - id: zizmor From 618339e2d2e9313ab8f2da0d78efec25477c4b43 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 6 Jan 2025 07:28:38 +1100 Subject: [PATCH 25/32] Allow saving multiple frames as BigTIFF --- Tests/test_file_tiff.py | 19 +++++++++-- src/PIL/TiffImagePlugin.py | 69 +++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index dedd48c20b3..c4a33488136 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -117,10 +117,16 @@ def test_bigtiff(self, tmp_path: Path) -> None: def test_bigtiff_save(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") - hopper().save(outfile, big_tiff=True) + im = hopper() + im.save(outfile, big_tiff=True) - with Image.open(outfile) as im: - assert im.tag_v2._bigtiff is True + with Image.open(outfile) as reloaded: + assert reloaded.tag_v2._bigtiff is True + + im.save(outfile, save_all=True, append_images=[im], big_tiff=True) + + with Image.open(outfile) as reloaded: + assert reloaded.tag_v2._bigtiff is True def test_seek_too_large(self) -> None: with pytest.raises(ValueError, match="Unable to seek to frame"): @@ -753,6 +759,13 @@ def test_fixoffsets(self) -> None: with pytest.raises(RuntimeError): a.fixOffsets(1) + def test_appending_tiff_writer_writelong(self) -> None: + data = b"II\x2A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b = BytesIO(data) + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.writeLong(2**32 - 1) + assert b.getvalue() == data + b"\xff\xff\xff\xff" + def test_saving_icc_profile(self, tmp_path: Path) -> None: # Tests saving TIFF with icc_profile set. # At the time of writing this will only work for non-compressed tiffs diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 61eb1524311..5dd56d92b5a 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -962,13 +962,16 @@ def tobytes(self, offset: int = 0) -> bytes: result = self._pack("Q" if self._bigtiff else "H", len(self._tags_v2)) entries: list[tuple[int, int, int, bytes, bytes]] = [] - offset += len(result) + len(self._tags_v2) * (20 if self._bigtiff else 12) + 4 + + fmt = "Q" if self._bigtiff else "L" + fmt_size = 8 if self._bigtiff else 4 + offset += ( + len(result) + len(self._tags_v2) * (20 if self._bigtiff else 12) + fmt_size + ) stripoffsets = None # pass 1: convert tags to binary format # always write tags in ascending order - fmt = "Q" if self._bigtiff else "L" - fmt_size = 8 if self._bigtiff else 4 for tag, value in sorted(self._tags_v2.items()): if tag == STRIPOFFSETS: stripoffsets = len(entries) @@ -1024,7 +1027,7 @@ def tobytes(self, offset: int = 0) -> bytes: ) # -- overwrite here for multi-page -- - result += b"\0\0\0\0" # end of entries + result += self._pack(fmt, 0) # end of entries # pass 3: write auxiliary data to file for tag, typ, count, value, data in entries: @@ -2043,20 +2046,21 @@ def setup(self) -> None: self.offsetOfNewPage = 0 self.IIMM = iimm = self.f.read(4) + self._bigtiff = b"\x2B" in iimm if not iimm: # empty file - first page self.isFirst = True return self.isFirst = False - if iimm == b"II\x2a\x00": - self.setEndian("<") - elif iimm == b"MM\x00\x2a": - self.setEndian(">") - else: + if iimm not in PREFIXES: msg = "Invalid TIFF file header" raise RuntimeError(msg) + self.setEndian("<" if iimm.startswith(II) else ">") + + if self._bigtiff: + self.f.seek(4, os.SEEK_CUR) self.skipIFDs() self.goToEnd() @@ -2076,11 +2080,13 @@ def finalize(self) -> None: msg = "IIMM of new page doesn't match IIMM of first page" raise RuntimeError(msg) - ifd_offset = self.readLong() + if self._bigtiff: + self.f.seek(4, os.SEEK_CUR) + ifd_offset = self._read(8 if self._bigtiff else 4) ifd_offset += self.offsetOfNewPage assert self.whereToWriteNewIFDOffset is not None self.f.seek(self.whereToWriteNewIFDOffset) - self.writeLong(ifd_offset) + self._write(ifd_offset, 8 if self._bigtiff else 4) self.f.seek(ifd_offset) self.fixIFD() @@ -2126,18 +2132,20 @@ def setEndian(self, endian: str) -> None: self.endian = endian self.longFmt = f"{self.endian}L" self.shortFmt = f"{self.endian}H" - self.tagFormat = f"{self.endian}HHL" + self.tagFormat = f"{self.endian}HH" + ("Q" if self._bigtiff else "L") def skipIFDs(self) -> None: while True: - ifd_offset = self.readLong() + ifd_offset = self._read(8 if self._bigtiff else 4) if ifd_offset == 0: - self.whereToWriteNewIFDOffset = self.f.tell() - 4 + self.whereToWriteNewIFDOffset = self.f.tell() - ( + 8 if self._bigtiff else 4 + ) break self.f.seek(ifd_offset) - num_tags = self.readShort() - self.f.seek(num_tags * 12, os.SEEK_CUR) + num_tags = self._read(8 if self._bigtiff else 2) + self.f.seek(num_tags * (20 if self._bigtiff else 12), os.SEEK_CUR) def write(self, data: Buffer, /) -> int: return self.f.write(data) @@ -2185,13 +2193,17 @@ def rewriteLastShort(self, value: int) -> None: def rewriteLastLong(self, value: int) -> None: return self._rewriteLast(value, 4) + def _write(self, value: int, field_size: int) -> None: + bytes_written = self.f.write( + struct.pack(self.endian + self._fmt(field_size), value) + ) + self._verify_bytes_written(bytes_written, field_size) + def writeShort(self, value: int) -> None: - bytes_written = self.f.write(struct.pack(self.shortFmt, value)) - self._verify_bytes_written(bytes_written, 2) + self._write(value, 2) def writeLong(self, value: int) -> None: - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - self._verify_bytes_written(bytes_written, 4) + self._write(value, 4) def close(self) -> None: self.finalize() @@ -2199,24 +2211,27 @@ def close(self) -> None: self.f.close() def fixIFD(self) -> None: - num_tags = self.readShort() + num_tags = self._read(8 if self._bigtiff else 2) for i in range(num_tags): - tag, field_type, count = struct.unpack(self.tagFormat, self.f.read(8)) + tag, field_type, count = struct.unpack( + self.tagFormat, self.f.read(12 if self._bigtiff else 8) + ) field_size = self.fieldSizes[field_type] total_size = field_size * count - is_local = total_size <= 4 + fmt_size = 8 if self._bigtiff else 4 + is_local = total_size <= fmt_size if not is_local: - offset = self.readLong() + self.offsetOfNewPage - self.rewriteLastLong(offset) + offset = self._read(fmt_size) + self.offsetOfNewPage + self._rewriteLast(offset, fmt_size) if tag in self.Tags: cur_pos = self.f.tell() if is_local: self._fixOffsets(count, field_size) - self.f.seek(cur_pos + 4) + self.f.seek(cur_pos + fmt_size) else: self.f.seek(offset) self._fixOffsets(count, field_size) @@ -2224,7 +2239,7 @@ def fixIFD(self) -> None: elif is_local: # skip the locally stored value that is not an offset - self.f.seek(4, os.SEEK_CUR) + self.f.seek(fmt_size, os.SEEK_CUR) def _fixOffsets(self, count: int, field_size: int) -> None: for i in range(count): From a8381c619de0c244785377322ed8bf115a899146 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 6 Jan 2025 07:28:51 +1100 Subject: [PATCH 26/32] Allow upgrading LONG to LONG8 --- Tests/test_file_tiff.py | 26 ++++++++++++++++++++++++- src/PIL/TiffImagePlugin.py | 39 ++++++++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index c4a33488136..757d3f96a5f 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -746,7 +746,7 @@ def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: assert reread.n_frames == 3 def test_fixoffsets(self) -> None: - b = BytesIO(b"II\x2a\x00\x00\x00\x00\x00") + b = BytesIO(b"II\x2A\x00\x00\x00\x00\x00") with TiffImagePlugin.AppendingTiffWriter(b) as a: b.seek(0) a.fixOffsets(1, isShort=True) @@ -759,6 +759,23 @@ def test_fixoffsets(self) -> None: with pytest.raises(RuntimeError): a.fixOffsets(1) + b = BytesIO(b"II\x2A\x00\x00\x00\x00\x00") + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.offsetOfNewPage = 2**16 + + b.seek(0) + a.fixOffsets(1, isShort=True) + + b = BytesIO(b"II\x2B\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.offsetOfNewPage = 2**32 + + b.seek(0) + a.fixOffsets(1, isShort=True) + + b.seek(0) + a.fixOffsets(1, isLong=True) + def test_appending_tiff_writer_writelong(self) -> None: data = b"II\x2A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b = BytesIO(data) @@ -766,6 +783,13 @@ def test_appending_tiff_writer_writelong(self) -> None: a.writeLong(2**32 - 1) assert b.getvalue() == data + b"\xff\xff\xff\xff" + def test_appending_tiff_writer_rewritelastshorttolong(self) -> None: + data = b"II\x2A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b = BytesIO(data) + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.rewriteLastShortToLong(2**32 - 1) + assert b.getvalue() == data[:-2] + b"\xff\xff\xff\xff" + def test_saving_icc_profile(self, tmp_path: Path) -> None: # Tests saving TIFF with icc_profile set. # At the time of writing this will only work for non-compressed tiffs diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 5dd56d92b5a..8179b7f5bd9 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -2175,17 +2175,19 @@ def _verify_bytes_written(bytes_written: int | None, expected: int) -> None: msg = f"wrote only {bytes_written} bytes but wanted {expected}" raise RuntimeError(msg) - def rewriteLastShortToLong(self, value: int) -> None: - self.f.seek(-2, os.SEEK_CUR) - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - self._verify_bytes_written(bytes_written, 4) - - def _rewriteLast(self, value: int, field_size: int) -> None: + def _rewriteLast( + self, value: int, field_size: int, new_field_size: int = 0 + ) -> None: self.f.seek(-field_size, os.SEEK_CUR) + if not new_field_size: + new_field_size = field_size bytes_written = self.f.write( - struct.pack(self.endian + self._fmt(field_size), value) + struct.pack(self.endian + self._fmt(new_field_size), value) ) - self._verify_bytes_written(bytes_written, field_size) + self._verify_bytes_written(bytes_written, new_field_size) + + def rewriteLastShortToLong(self, value: int) -> None: + self._rewriteLast(value, 2, 4) def rewriteLastShort(self, value: int) -> None: return self._rewriteLast(value, 2) @@ -2245,18 +2247,27 @@ def _fixOffsets(self, count: int, field_size: int) -> None: for i in range(count): offset = self._read(field_size) offset += self.offsetOfNewPage - if field_size == 2 and offset >= 65536: - # offset is now too large - we must convert shorts to longs + + new_field_size = 0 + if self._bigtiff and field_size in (2, 4) and offset >= 2**32: + # offset is now too large - we must convert long to long8 + new_field_size = 8 + elif field_size == 2 and offset >= 2**16: + # offset is now too large - we must convert short to long + new_field_size = 4 + if new_field_size: if count != 1: msg = "not implemented" raise RuntimeError(msg) # XXX TODO # simple case - the offset is just one and therefore it is # local (not referenced with another offset) - self.rewriteLastShortToLong(offset) - self.f.seek(-10, os.SEEK_CUR) - self.writeShort(TiffTags.LONG) # rewrite the type to LONG - self.f.seek(8, os.SEEK_CUR) + self._rewriteLast(offset, field_size, new_field_size) + # Move back past the new offset, past 'count', and before 'field_type' + rewind = -new_field_size - 4 - 2 + self.f.seek(rewind, os.SEEK_CUR) + self.writeShort(new_field_size) # rewrite the type + self.f.seek(2 - rewind, os.SEEK_CUR) else: self._rewriteLast(offset, field_size) From aef6df2d04bfe86b4a69ab7d93786ea55a3e7340 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 7 Jan 2025 21:33:57 +1100 Subject: [PATCH 27/32] Use ImageFile._Tile --- Tests/test_file_jpeg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index dd62460bb5d..526c6a5b614 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1030,7 +1030,7 @@ def decode( with Image.open(TEST_FILE) as im: im.tile = [ - ("INFINITE", (0, 0, 128, 128), 0, ("RGB", 0, 1)), + ImageFile._Tile("INFINITE", (0, 0, 128, 128), 0, ("RGB", 0, 1)), ] ImageFile.LOAD_TRUNCATED_IMAGES = True im.load() From f36c66746705245dec44b225868bce727dca0385 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 7 Jan 2025 22:24:08 +1100 Subject: [PATCH 28/32] Improved test coverage --- Tests/test_file_spider.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 4cafda86536..713db848df8 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -7,7 +7,7 @@ import pytest -from PIL import Image, ImageSequence, SpiderImagePlugin +from PIL import Image, SpiderImagePlugin from .helper import assert_image_equal, hopper, is_pypy @@ -153,8 +153,8 @@ def test_nonstack_file() -> None: def test_nonstack_dos() -> None: with Image.open(TEST_FILE) as im: - for i, frame in enumerate(ImageSequence.Iterator(im)): - assert i <= 1, "Non-stack DOS file test failed" + with pytest.raises(EOFError): + im.seek(0) # for issue #4093 From 86b8e1e45fa1d425aafa444024003eb3b75d8a9d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 8 Jan 2025 10:19:09 +1100 Subject: [PATCH 29/32] Updated libpng to 1.6.45 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 58621bca1ed..410255b7eda 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -39,7 +39,7 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.3 HARFBUZZ_VERSION=10.1.0 -LIBPNG_VERSION=1.6.44 +LIBPNG_VERSION=1.6.45 JPEGTURBO_VERSION=3.1.0 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.6.3 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 75d6aa1bd2d..912579ce790 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -116,7 +116,7 @@ def cmd_msbuild( "HARFBUZZ": "10.1.0", "JPEGTURBO": "3.1.0", "LCMS2": "2.16", - "LIBPNG": "1.6.44", + "LIBPNG": "1.6.45", "LIBWEBP": "1.5.0", "OPENJPEG": "2.5.3", "TIFF": "4.6.0", From ee2b8c525632f76bde730805d11d19bbb22f1b2b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 8 Jan 2025 10:26:21 +1100 Subject: [PATCH 30/32] Switch to .tar.gz for libpng --- winbuild/build_prepare.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 912579ce790..b9695d1d802 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -123,7 +123,6 @@ def cmd_msbuild( "XZ": "5.6.3", "ZLIBNG": "2.2.3", } -V["LIBPNG_DOTLESS"] = V["LIBPNG"].replace(".", "") V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) @@ -241,8 +240,8 @@ def cmd_msbuild( }, "libpng": { "url": f"{SF_PROJECTS}/libpng/files/libpng{V['LIBPNG_XY']}/{V['LIBPNG']}/" - f"lpng{V['LIBPNG_DOTLESS']}.zip/download", - "filename": f"lpng{V['LIBPNG_DOTLESS']}.zip", + f"FILENAME/download", + "filename": f"libpng-{V['LIBPNG']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake("png_static", "-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF"), From f281eb9b469320f29006d7454d9c38a974ad65c1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 8 Jan 2025 18:27:20 +1100 Subject: [PATCH 31/32] Trigger from changes in pyproject.toml --- .github/workflows/wheels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3b22ee98a2c..fd89f7585ee 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,6 +13,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/workflows/wheel*" + - "pyproject.toml" - "setup.py" - "wheels/*" - "winbuild/build_prepare.py" @@ -23,6 +24,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/workflows/wheel*" + - "pyproject.toml" - "setup.py" - "wheels/*" - "winbuild/build_prepare.py" From 84c8e38b2d88d0c4fb733d9c6e44e6af13e2e05e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 07:38:51 +0000 Subject: [PATCH 32/32] Update cygwin/cygwin-install-action action to v5 --- .github/workflows/test-cygwin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 5b0a0394688..abfeaa77f9c 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: Install Cygwin - uses: cygwin/cygwin-install-action@v4 + uses: cygwin/cygwin-install-action@v5 with: packages: > gcc-g++