From 7f68decf2c34ad4bc2a700be8410df679180b8d9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 22:16:51 +1100 Subject: [PATCH 01/19] Clarified condition --- src/PIL/PngImagePlugin.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 76a15bd0d..9bfeb1104 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1443,9 +1443,9 @@ def _save( palette_bytes += b"\0" chunk(fp, b"PLTE", palette_bytes) - transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None)) + transparency = im.encoderinfo.get("transparency", im.info.get("transparency")) - if transparency or transparency == 0: + if transparency is not None: if im.mode == "P": # limit to actual palette size alpha_bytes = colors @@ -1461,17 +1461,15 @@ def _save( elif im.mode == "RGB": red, green, blue = transparency chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) - else: - if "transparency" in im.encoderinfo: - # don't bother with transparency if it's an RGBA - # and it's in the info dict. It's probably just stale. - msg = "cannot use transparency for this mode" - raise OSError(msg) - else: - if im.mode == "P" and im.im.getpalettemode() == "RGBA": - alpha = im.im.getpalette("RGBA", "A") - alpha_bytes = colors - chunk(fp, b"tRNS", alpha[:alpha_bytes]) + elif im.encoderinfo.get("transparency") is not None: + # don't bother with transparency if it's an RGBA + # and it's in the info dict. It's probably just stale. + msg = "cannot use transparency for this mode" + raise OSError(msg) + elif im.mode == "P" and im.im.getpalettemode() == "RGBA": + alpha = im.im.getpalette("RGBA", "A") + alpha_bytes = colors + chunk(fp, b"tRNS", alpha[:alpha_bytes]) if dpi := im.encoderinfo.get("dpi"): chunk( From e58c67347a2f4eae454fa727008fa1ba4a71c923 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 22:19:52 +1100 Subject: [PATCH 02/19] Raise error if transparency is incorrect type or length when saving --- Tests/test_file_png.py | 35 +++++++++++++++++++++++++++++++++-- src/PIL/PngImagePlugin.py | 24 +++++++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3f08d1ad3..0fad0b391 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -502,8 +502,9 @@ class TestFilePng: im = roundtrip(im) assert im.info["transparency"] == (248, 248, 248) - im = roundtrip(im, transparency=(0, 1, 2)) - assert im.info["transparency"] == (0, 1, 2) + for transparency in ((0, 1, 2), [0, 1, 2]): + im = roundtrip(im, transparency=transparency) + assert im.info["transparency"] == (0, 1, 2) def test_trns_p(self, tmp_path: Path) -> None: # Check writing a transparency of 0, issue #528 @@ -518,6 +519,36 @@ class TestFilePng: assert_image_equal(im2.convert("RGBA"), im.convert("RGBA")) + def test_trns_invalid(self, tmp_path: Path) -> None: + out = tmp_path / "temp.png" + + for mode in ("1", "L", "I;16"): + im = Image.new(mode, (1, 1)) + with pytest.raises( + ValueError, match=f"transparency for {mode} must be an integer" + ): + im.save(out, transparency="invalid") + + im = Image.new("I", (1, 1)) + with pytest.warns(DeprecationWarning, match="Saving I mode images as PNG"): + with pytest.raises(ValueError): + im.save(out, transparency="invalid") + + im = Image.new("P", (1, 1)) + with pytest.raises( + ValueError, match="transparency for P must be an integer or bytes" + ): + im.save(out, transparency="invalid") + + im = Image.new("RGB", (1, 1)) + with pytest.raises( + ValueError, match="transparency for RGB must be list or tuple" + ): + im.save(out, transparency="invalid") + + with pytest.raises(ValueError, match="transparency for RGB must have length 3"): + im.save(out, transparency=(1, 2)) + def test_trns_null(self) -> None: # Check reading images with null tRNS value, issue #1239 test_file = "Tests/images/tRNS_null_1x1.png" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9bfeb1104..3f21fa48e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1451,16 +1451,30 @@ def _save( alpha_bytes = colors if isinstance(transparency, bytes): chunk(fp, b"tRNS", transparency[:alpha_bytes]) - else: + elif isinstance(transparency, int): transparency = max(0, min(255, transparency)) alpha = b"\xff" * transparency + b"\0" chunk(fp, b"tRNS", alpha[:alpha_bytes]) + else: + msg = "transparency for P must be an integer or bytes" + raise ValueError(msg) elif im.mode in ("1", "L", "I", "I;16"): - transparency = max(0, min(65535, transparency)) - chunk(fp, b"tRNS", o16(transparency)) + if isinstance(transparency, int): + transparency = max(0, min(65535, transparency)) + chunk(fp, b"tRNS", o16(transparency)) + else: + msg = f"transparency for {im.mode} must be an integer" + raise ValueError(msg) elif im.mode == "RGB": - red, green, blue = transparency - chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) + if not isinstance(transparency, (list, tuple)): + msg = "transparency for RGB must be list or tuple" + raise ValueError(msg) + elif len(transparency) != 3: + msg = "transparency for RGB must have length 3" + raise ValueError(msg) + else: + red, green, blue = transparency + chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) elif im.encoderinfo.get("transparency") is not None: # don't bother with transparency if it's an RGBA # and it's in the info dict. It's probably just stale. From 99869f031342ad718721b66a791bf0b6eba2fb5e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:14:58 +0300 Subject: [PATCH 03/19] Sort things alphabetically to make easier to find --- .github/generate-sbom.py | 386 +++++++++++++++++++-------------------- 1 file changed, 193 insertions(+), 193 deletions(-) diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index 9e65121e6..fb9b37f27 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -79,18 +79,18 @@ def generate(version: str) -> dict: } c_extensions = [ + ("PIL._avif", "AVIF image format extension"), ( "PIL._imaging", "Core image processing extension " "(decode, encode, map, display, outline, path, libImaging)", ), - ("PIL._imagingft", "FreeType font rendering extension"), ("PIL._imagingcms", "LittleCMS2 colour management extension"), - ("PIL._webp", "WebP image format extension"), - ("PIL._avif", "AVIF image format extension"), - ("PIL._imagingtk", "Tk/Tcl display extension"), + ("PIL._imagingft", "FreeType font rendering extension"), ("PIL._imagingmath", "Image math operations extension"), ("PIL._imagingmorph", "Image morphology extension"), + ("PIL._imagingtk", "Tk/Tcl display extension"), + ("PIL._webp", "WebP image format extension"), ] ext_components = [ @@ -107,6 +107,51 @@ def generate(version: str) -> dict: ] vendored_components = [ + { + "bom-ref": f"{purl}#thirdparty/fribidi-shim", + "type": "library", + "name": "fribidi-shim", + "version": "1.x", + "description": "FriBiDi runtime-loading shim " + "(vendored in src/thirdparty/fribidi-shim/); " + "loads libfribidi dynamically", + "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"), + } + ], + "pedigree": { + "notes": "Pillow-authored shim; not taken from an upstream project." + }, + "externalReferences": [ + {"type": "website", "url": "https://github.com/fribidi/fribidi"}, + ], + }, + { + "bom-ref": "pkg:github/python/pythoncapi-compat", + "type": "library", + "name": "pythoncapi_compat", + "description": "Backport header for new CPython C-API functions " + "(vendored in src/thirdparty/pythoncapi_compat.h)", + "licenses": [{"license": {"id": "0BSD"}}], + "hashes": [ + { + "alg": "SHA-256", + "content": sha256_file(thirdparty / "pythoncapi_compat.h"), + } + ], + "pedigree": { + "notes": "Vendored unmodified from upstream python/pythoncapi-compat." + }, + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/python/pythoncapi-compat", + }, + ], + }, { "bom-ref": f"{purl}#thirdparty/raqm", "type": "library", @@ -191,54 +236,89 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": f"{purl}#thirdparty/fribidi-shim", - "type": "library", - "name": "fribidi-shim", - "version": "1.x", - "description": "FriBiDi runtime-loading shim " - "(vendored in src/thirdparty/fribidi-shim/); " - "loads libfribidi dynamically", - "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], - "hashes": [ - { - "alg": "SHA-256", - "content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"), - } - ], - "pedigree": { - "notes": "Pillow-authored shim; not taken from an upstream project." - }, - "externalReferences": [ - {"type": "website", "url": "https://github.com/fribidi/fribidi"}, - ], - }, - { - "bom-ref": "pkg:github/python/pythoncapi-compat", - "type": "library", - "name": "pythoncapi_compat", - "description": "Backport header for new CPython C-API functions " - "(vendored in src/thirdparty/pythoncapi_compat.h)", - "licenses": [{"license": {"id": "0BSD"}}], - "hashes": [ - { - "alg": "SHA-256", - "content": sha256_file(thirdparty / "pythoncapi_compat.h"), - } - ], - "pedigree": { - "notes": "Vendored unmodified from upstream python/pythoncapi-compat." - }, - "externalReferences": [ - { - "type": "vcs", - "url": "https://github.com/python/pythoncapi-compat", - }, - ], - }, ] native_deps = [ + { + "bom-ref": "pkg:generic/freetype2", + "type": "library", + "name": "FreeType", + "scope": "optional", + "description": "Font rendering (optional, used by PIL._imagingft). " + "Required for text/font support.", + "licenses": [{"license": {"id": "FTL"}}], + "externalReferences": [ + {"type": "website", "url": "https://freetype.org"}, + { + "type": "distribution", + "url": "https://download.savannah.gnu.org/releases/freetype/", + }, + ], + }, + { + "bom-ref": "pkg:generic/fribidi", + "type": "library", + "name": "FriBiDi", + "scope": "optional", + "description": "Unicode bidi algorithm library (optional, " + "loaded at runtime by fribidi-shim).", + "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], + "externalReferences": [ + {"type": "website", "url": "https://github.com/fribidi/fribidi"}, + { + "type": "distribution", + "url": "https://github.com/fribidi/fribidi/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/harfbuzz", + "type": "library", + "name": "HarfBuzz", + "scope": "optional", + "description": "Text shaping (optional, required by libraqm " + "for complex text layout).", + "licenses": [{"license": {"id": "MIT"}}], + "externalReferences": [ + {"type": "website", "url": "https://harfbuzz.github.io"}, + { + "type": "distribution", + "url": "https://github.com/harfbuzz/harfbuzz/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libavif", + "type": "library", + "name": "libavif", + "scope": "optional", + "description": "AVIF codec (optional, used by PIL._avif). " + "Requires libavif >= 1.0.0.", + "licenses": [{"license": {"id": "BSD-2-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, + { + "type": "distribution", + "url": "https://github.com/AOMediaCodec/libavif/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/libimagequant", + "type": "library", + "name": "libimagequant", + "scope": "optional", + "description": "Improved colour quantization (optional). " + "Tested with 2.6-4.4.1.", + "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], + "externalReferences": [ + {"type": "website", "url": "https://pngquant.org/lib/"}, + { + "type": "distribution", + "url": "https://github.com/ImageOptim/libimagequant/tags", + }, + ], + }, { "bom-ref": "pkg:generic/libjpeg", "type": "library", @@ -259,18 +339,6 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": "pkg:generic/zlib", - "type": "library", - "name": "zlib", - "description": "Deflate/PNG compression (required by default; " - "disable with -C zlib=disable).", - "licenses": [{"license": {"id": "Zlib"}}], - "externalReferences": [ - {"type": "website", "url": "https://zlib.net"}, - {"type": "distribution", "url": "https://zlib.net"}, - ], - }, { "bom-ref": "pkg:generic/libtiff", "type": "library", @@ -286,38 +354,6 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": "pkg:generic/freetype2", - "type": "library", - "name": "FreeType", - "scope": "optional", - "description": "Font rendering (optional, used by PIL._imagingft). " - "Required for text/font support.", - "licenses": [{"license": {"id": "FTL"}}], - "externalReferences": [ - {"type": "website", "url": "https://freetype.org"}, - { - "type": "distribution", - "url": "https://download.savannah.gnu.org/releases/freetype/", - }, - ], - }, - { - "bom-ref": "pkg:generic/littlecms2", - "type": "library", - "name": "Little CMS 2", - "scope": "optional", - "description": "Colour management (optional, used by PIL._imagingcms). " - "Tested with lcms2 2.7-2.18.", - "licenses": [{"license": {"id": "MIT"}}], - "externalReferences": [ - {"type": "website", "url": "https://www.littlecms.com"}, - { - "type": "distribution", - "url": "https://github.com/mm2/Little-CMS/releases", - }, - ], - }, { "bom-ref": "pkg:generic/libwebp", "type": "library", @@ -336,86 +372,6 @@ def generate(version: str) -> dict: }, ], }, - { - "bom-ref": "pkg:generic/openjpeg", - "type": "library", - "name": "OpenJPEG", - "scope": "optional", - "description": "JPEG 2000 codec (optional). " - "Tested with openjpeg 2.0.0-2.5.4.", - "licenses": [{"license": {"id": "BSD-2-Clause"}}], - "externalReferences": [ - {"type": "website", "url": "https://www.openjpeg.org"}, - { - "type": "distribution", - "url": "https://github.com/uclouvain/openjpeg/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/libavif", - "type": "library", - "name": "libavif", - "scope": "optional", - "description": "AVIF codec (optional, used by PIL._avif). " - "Requires libavif >= 1.0.0.", - "licenses": [{"license": {"id": "BSD-2-Clause"}}], - "externalReferences": [ - {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, - { - "type": "distribution", - "url": "https://github.com/AOMediaCodec/libavif/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/harfbuzz", - "type": "library", - "name": "HarfBuzz", - "scope": "optional", - "description": "Text shaping (optional, required by libraqm " - "for complex text layout).", - "licenses": [{"license": {"id": "MIT"}}], - "externalReferences": [ - {"type": "website", "url": "https://harfbuzz.github.io"}, - { - "type": "distribution", - "url": "https://github.com/harfbuzz/harfbuzz/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/fribidi", - "type": "library", - "name": "FriBiDi", - "scope": "optional", - "description": "Unicode bidi algorithm library (optional, " - "loaded at runtime by fribidi-shim).", - "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}], - "externalReferences": [ - {"type": "website", "url": "https://github.com/fribidi/fribidi"}, - { - "type": "distribution", - "url": "https://github.com/fribidi/fribidi/releases", - }, - ], - }, - { - "bom-ref": "pkg:generic/libimagequant", - "type": "library", - "name": "libimagequant", - "scope": "optional", - "description": "Improved colour quantization (optional). " - "Tested with 2.6-4.4.1.", - "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], - "externalReferences": [ - {"type": "website", "url": "https://pngquant.org/lib/"}, - { - "type": "distribution", - "url": "https://github.com/ImageOptim/libimagequant/tags", - }, - ], - }, { "bom-ref": "pkg:generic/libxcb", "type": "library", @@ -432,6 +388,38 @@ def generate(version: str) -> dict: }, ], }, + { + "bom-ref": "pkg:generic/littlecms2", + "type": "library", + "name": "Little CMS 2", + "scope": "optional", + "description": "Colour management (optional, used by PIL._imagingcms). " + "Tested with lcms2 2.7-2.18.", + "licenses": [{"license": {"id": "MIT"}}], + "externalReferences": [ + {"type": "website", "url": "https://www.littlecms.com"}, + { + "type": "distribution", + "url": "https://github.com/mm2/Little-CMS/releases", + }, + ], + }, + { + "bom-ref": "pkg:generic/openjpeg", + "type": "library", + "name": "OpenJPEG", + "scope": "optional", + "description": "JPEG 2000 codec (optional). " + "Tested with openjpeg 2.0.0-2.5.4.", + "licenses": [{"license": {"id": "BSD-2-Clause"}}], + "externalReferences": [ + {"type": "website", "url": "https://www.openjpeg.org"}, + { + "type": "distribution", + "url": "https://github.com/uclouvain/openjpeg/releases", + }, + ], + }, { "bom-ref": "pkg:pypi/pybind11", "type": "library", @@ -447,51 +435,63 @@ def generate(version: str) -> dict: }, ], }, + { + "bom-ref": "pkg:generic/zlib", + "type": "library", + "name": "zlib", + "description": "Deflate/PNG compression (required by default; " + "disable with -C zlib=disable).", + "licenses": [{"license": {"id": "Zlib"}}], + "externalReferences": [ + {"type": "website", "url": "https://zlib.net"}, + {"type": "distribution", "url": "https://zlib.net"}, + ], + }, ] dependencies = [ { "ref": purl, - "dependsOn": [e["bom-ref"] for e in ext_components], + "dependsOn": sorted(e["bom-ref"] for e in ext_components), + }, + { + "ref": f"{purl}#c-ext/PIL._avif", + "dependsOn": ["pkg:generic/libavif"], }, { "ref": f"{purl}#c-ext/PIL._imaging", "dependsOn": [ - "pkg:generic/libjpeg", - "pkg:generic/zlib", - "pkg:generic/libtiff", - "pkg:generic/openjpeg", "pkg:generic/libimagequant", + "pkg:generic/libjpeg", + "pkg:generic/libtiff", "pkg:generic/libxcb", - ], - }, - { - "ref": f"{purl}#c-ext/PIL._imagingft", - "dependsOn": [ - "pkg:generic/freetype2", - f"{purl}#thirdparty/raqm", - f"{purl}#thirdparty/fribidi-shim", - "pkg:generic/harfbuzz", - "pkg:generic/fribidi", + "pkg:generic/openjpeg", + "pkg:generic/zlib", ], }, { "ref": f"{purl}#c-ext/PIL._imagingcms", "dependsOn": ["pkg:generic/littlecms2"], }, + { + "ref": f"{purl}#c-ext/PIL._imagingft", + "dependsOn": [ + "pkg:generic/freetype2", + "pkg:generic/fribidi", + "pkg:generic/harfbuzz", + f"{purl}#thirdparty/fribidi-shim", + f"{purl}#thirdparty/raqm", + ], + }, { "ref": f"{purl}#c-ext/PIL._webp", "dependsOn": ["pkg:generic/libwebp"], }, - { - "ref": f"{purl}#c-ext/PIL._avif", - "dependsOn": ["pkg:generic/libavif"], - }, { "ref": f"{purl}#thirdparty/raqm", "dependsOn": [ - f"{purl}#thirdparty/fribidi-shim", "pkg:generic/harfbuzz", + f"{purl}#thirdparty/fribidi-shim", ], }, ] From f2ee74b2f8756f7ff162d65b0e501c635a169e24 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:19:01 +0300 Subject: [PATCH 04/19] Use versions from dependencies.json, remove historical 'tested on' --- .github/generate-sbom.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index fb9b37f27..c041300f2 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -26,6 +26,11 @@ def get_version() -> str: return version_file.read_text(encoding="utf-8").split('"')[1] +def load_dep_versions() -> dict[str, str]: + deps_file = Path(__file__).parent / "dependencies.json" + return json.loads(deps_file.read_text(encoding="utf-8")) + + def sha256_file(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -58,6 +63,7 @@ def generate(version: str) -> dict: purl = f"pkg:pypi/pillow@{version}" root = Path(__file__).parent.parent thirdparty = root / "src" / "thirdparty" + versions = load_dep_versions() metadata_component = { "bom-ref": purl, @@ -243,6 +249,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/freetype2", "type": "library", "name": "FreeType", + "version": versions["freetype"], "scope": "optional", "description": "Font rendering (optional, used by PIL._imagingft). " "Required for text/font support.", @@ -259,6 +266,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/fribidi", "type": "library", "name": "FriBiDi", + "version": versions["fribidi"], "scope": "optional", "description": "Unicode bidi algorithm library (optional, " "loaded at runtime by fribidi-shim).", @@ -275,6 +283,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/harfbuzz", "type": "library", "name": "HarfBuzz", + "version": versions["harfbuzz"], "scope": "optional", "description": "Text shaping (optional, required by libraqm " "for complex text layout).", @@ -291,9 +300,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libavif", "type": "library", "name": "libavif", + "version": versions["libavif"], "scope": "optional", - "description": "AVIF codec (optional, used by PIL._avif). " - "Requires libavif >= 1.0.0.", + "description": "AVIF codec (optional, used by PIL._avif).", "licenses": [{"license": {"id": "BSD-2-Clause"}}], "externalReferences": [ {"type": "website", "url": "https://github.com/AOMediaCodec/libavif"}, @@ -307,9 +316,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libimagequant", "type": "library", "name": "libimagequant", + "version": versions["libimagequant"], "scope": "optional", - "description": "Improved colour quantization (optional). " - "Tested with 2.6-4.4.1.", + "description": "Improved colour quantization (optional).", "licenses": [{"license": {"id": "GPL-3.0-or-later"}}], "externalReferences": [ {"type": "website", "url": "https://pngquant.org/lib/"}, @@ -323,9 +332,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libjpeg", "type": "library", "name": "libjpeg / libjpeg-turbo", + "version": versions["jpegturbo"], "description": "JPEG codec (required by default; disable with " - "-C jpeg=disable). Tested with libjpeg 6b/8/9-9d " - "and libjpeg-turbo 2-3.", + "-C jpeg=disable).", "licenses": [ {"license": {"id": "IJG"}}, {"license": {"id": "BSD-3-Clause"}}, @@ -343,8 +352,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libtiff", "type": "library", "name": "libtiff", + "version": versions["tiff"], "scope": "optional", - "description": "TIFF codec (optional). Tested with libtiff 4.0-4.7.1.", + "description": "TIFF codec (optional).", "licenses": [{"license": {"id": "libtiff"}}], "externalReferences": [ {"type": "website", "url": "https://libtiff.gitlab.io/libtiff/"}, @@ -358,6 +368,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libwebp", "type": "library", "name": "libwebp", + "version": versions["libwebp"], "scope": "optional", "description": "WebP codec (optional, used by PIL._webp).", "licenses": [{"license": {"id": "BSD-3-Clause"}}], @@ -376,6 +387,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/libxcb", "type": "library", "name": "libxcb", + "version": versions["libxcb"], "scope": "optional", "description": "X11 screen-grab support (optional, " "used by PIL._imaging on macOS and Linux).", @@ -392,9 +404,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/littlecms2", "type": "library", "name": "Little CMS 2", + "version": versions["lcms2"], "scope": "optional", - "description": "Colour management (optional, used by PIL._imagingcms). " - "Tested with lcms2 2.7-2.18.", + "description": "Colour management (optional, used by PIL._imagingcms).", "licenses": [{"license": {"id": "MIT"}}], "externalReferences": [ {"type": "website", "url": "https://www.littlecms.com"}, @@ -408,9 +420,9 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/openjpeg", "type": "library", "name": "OpenJPEG", + "version": versions["openjpeg"], "scope": "optional", - "description": "JPEG 2000 codec (optional). " - "Tested with openjpeg 2.0.0-2.5.4.", + "description": "JPEG 2000 codec (optional).", "licenses": [{"license": {"id": "BSD-2-Clause"}}], "externalReferences": [ {"type": "website", "url": "https://www.openjpeg.org"}, @@ -439,6 +451,7 @@ def generate(version: str) -> dict: "bom-ref": "pkg:generic/zlib", "type": "library", "name": "zlib", + "version": versions["zlib-ng"], "description": "Deflate/PNG compression (required by default; " "disable with -C zlib=disable).", "licenses": [{"license": {"id": "Zlib"}}], From 3dda1d190f2136335eb10ff99987440d8743bf2e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:19:55 +0300 Subject: [PATCH 05/19] Git ignore generated SBOM --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3033c2ea7..4e9803957 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ pillow-test-images.zip # pyinstaller *.spec + +# Generated SBOM +pillow-*.cdx.json From 0ef81c33af3d4416feb96a0b1cd8c2d3b3d06ab7 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:30:17 +1000 Subject: [PATCH 06/19] Add Fedora 44 (#9594) --- .github/workflows/test-docker.yml | 1 + docs/installation/platform-support.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index b035ac1de..e868b53a8 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -45,6 +45,7 @@ jobs: debian-13-trixie-x86, debian-13-trixie-amd64, fedora-43-amd64, + fedora-44-amd64, gentoo, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index e90d989a2..90321d054 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -31,6 +31,8 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Fedora 43 | 3.14 | x86-64 | +----------------------------------+----------------------------+---------------------+ +| Fedora 44 | 3.14 | x86-64 | ++----------------------------------+----------------------------+---------------------+ | Gentoo | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 | From 1f3b8a831d9cd7e140081289259fd0cb5d90934f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Apr 2026 00:13:37 +1000 Subject: [PATCH 07/19] If PdfParser buffer is memoryview, release it when closing --- src/PIL/PdfParser.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 2a5ade773..b0b32b1c9 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -383,7 +383,7 @@ class PdfParser: msg = "specify buf or f or filename, but not both buf and f" raise RuntimeError(msg) self.filename = filename - self.buf: bytes | bytearray | mmap.mmap | None = buf + self.buf: bytes | bytearray | memoryview | mmap.mmap | None = buf self.f = f self.start_offset = start_offset self.should_close_buf = False @@ -435,7 +435,9 @@ class PdfParser: self.seek_end() def close_buf(self) -> None: - if isinstance(self.buf, mmap.mmap): + if isinstance(self.buf, memoryview): + self.buf.release() + elif isinstance(self.buf, mmap.mmap): self.buf.close() self.buf = None From 4af29fb7324cd05cd8b6f6bbf1ff2dddf0a6c573 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Apr 2026 18:41:41 +1000 Subject: [PATCH 08/19] Restrict SBOM upload to Pillow JSON --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 98733b6c7..d5af65c98 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -294,12 +294,12 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom - path: "*.cdx.json" + path: "pillow-*.cdx.json" - name: Validate SBOM run: | python3 -m pip install -r .ci/requirements-sbom.txt - check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" *.cdx.json + check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json sbom-publish: if: | @@ -320,7 +320,7 @@ jobs: - name: Attach SBOM to GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "$GITHUB_REF_NAME" *.cdx.json + run: gh release upload "$GITHUB_REF_NAME" pillow-*.cdx.json pypi-publish: if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') From fc47d0760381fd904e4db45295912ef01d7137ae Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:17:39 +0300 Subject: [PATCH 09/19] No need to sort a sorted list --- .github/generate-sbom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index c041300f2..3b15a7d91 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -465,7 +465,7 @@ def generate(version: str) -> dict: dependencies = [ { "ref": purl, - "dependsOn": sorted(e["bom-ref"] for e in ext_components), + "dependsOn": [e["bom-ref"] for e in ext_components], }, { "ref": f"{purl}#c-ext/PIL._avif", From 7e4ca8b3abf1476b0c956bbc3642d0b2d5717e7c Mon Sep 17 00:00:00 2001 From: Hayato Ikoma Date: Fri, 1 May 2026 21:36:20 -0700 Subject: [PATCH 10/19] Correct integer overflow in 16-bit resampling (#9480) Co-authored-by: Andrew Murray --- Tests/test_image_resample.py | 34 ++++++++++++++++++++++++++++++++++ src/libImaging/Convert.c | 2 -- src/libImaging/ImagingUtils.h | 2 ++ src/libImaging/Resample.c | 12 ++++++------ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 73b25ed51..f188be81e 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -627,3 +627,37 @@ class TestCoreResampleBox: 0.4, f">>> {size} {box} {flt}", ) + + +class TestCoreResample16bpc: + # Lanczos weighting during downsampling can push accumulated float sums + @pytest.mark.parametrize( + "offset", + ( + # below 0. These must be clamped to 0, not corrupted byte-by-byte. + 0, # Left half = 65535, right half = 0 + # above 65535. These must be clamped to 65535, not corrupted byte-by-byte. + 50, # # Left half = 0, right half = 65535 + ), + ) + def test_resampling_clamp_overflow(self, offset: int) -> None: + ims = {} + width, height = 100, 10 + for mode in ("I;16", "F"): + im = Image.new(mode, (width, height)) + im.paste(65535, (offset, 0, offset + width // 2, height)) + + # 5x downsampling with Lanczos + # creates ~8.7% overshoot or undershoot at the step edge + ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS) + + for y in range(height): + for x in range(20): + v = ims["F"].getpixel((x, y)) + assert isinstance(v, float) + expected = max(0, min(65535, round(v))) + + value = ims["I;16"].getpixel((x, y)) + assert ( + value == expected + ), f"Pixel ({x}, {y}): expected {expected}, got {value}" diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 002497c32..f156810ff 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -37,8 +37,6 @@ #define MAX(a, b) (a) > (b) ? (a) : (b) #define MIN(a, b) (a) < (b) ? (a) : (b) -#define CLIP16(v) ((v) <= 0 ? 0 : (v) >= 65535 ? 65535 : (v)) - /* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */ #define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114) #define L24(rgb) ((rgb)[0] * 19595 + (rgb)[1] * 38470 + (rgb)[2] * 7471 + 0x8000) diff --git a/src/libImaging/ImagingUtils.h b/src/libImaging/ImagingUtils.h index 714458ad0..a362780d0 100644 --- a/src/libImaging/ImagingUtils.h +++ b/src/libImaging/ImagingUtils.h @@ -27,6 +27,8 @@ #define CLIP8(v) ((v) <= 0 ? 0 : (v) < 256 ? (v) : 255) +#define CLIP16(v) ((v) <= 0 ? 0 : (v) < 65536 ? (v) : 65535) + /* This is to work around a bug in GCC prior 4.9 in 64 bit mode. GCC generates code with partial dependency which is 3 times slower. See: https://stackoverflow.com/a/26588074/253146 */ diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index fea00eea0..1647dca14 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -492,9 +492,9 @@ ImagingResampleHorizontal_16bpc( << 8)) * k[x]; } - ss_int = ROUND_UP(ss); - imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); - imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + ss_int = CLIP16(ROUND_UP(ss)); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF; + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8; } } ImagingSectionLeave(&cookie); @@ -531,9 +531,9 @@ ImagingResampleVertical_16bpc( (imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) * k[y]; } - ss_int = ROUND_UP(ss); - imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); - imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + ss_int = CLIP16(ROUND_UP(ss)); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF; + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8; } } ImagingSectionLeave(&cookie); From 2d02654c54c1584980fd239b58fefa7f1f8f4626 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 14:11:33 +1000 Subject: [PATCH 11/19] Update dependency cibuildwheel to v3.4.1 (#9607) --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index fd4183aff..c824c10bc 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.4.0 +cibuildwheel==3.4.1 From d92b826c4a4fff179b1f8c1ec421fefd78cdb26f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 06:03:07 +0000 Subject: [PATCH 12/19] Update github-actions --- .github/workflows/cifuzz.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/wheels.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 27b55cffc..a2e1112dc 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -30,14 +30,14 @@ jobs: steps: - name: Build Fuzzers id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master with: oss-fuzz-project-name: 'pillow' language: python dry-run: false - name: Run Fuzzers id: run - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master with: oss-fuzz-project-name: 'pillow' fuzz-seconds: 600 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1aff5a0dd..dacf40cc1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: with: python-version: "3.x" - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Lint run: uvx --with tox-uv tox -e lint - name: Mypy diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d5af65c98..e0edb3ac0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -270,7 +270,7 @@ jobs: path: dist merge-multiple: true - name: Upload wheels to scientific-python-nightly-wheels - uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3 + uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4 with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} From 3bbb7a2a04c748b70ff1061572c67099a787ecc9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:22 +0000 Subject: [PATCH 13/19] Update dependency libpng to v1.6.58 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..a5e9b01f7 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -8,7 +8,7 @@ "lcms2": "2.18", "libavif": "1.4.1", "libimagequant": "4.4.1", - "libpng": "1.6.56", + "libpng": "1.6.58", "libwebp": "1.6.0", "libxcb": "1.17.0", "openjpeg": "2.5.4", From 956d434c68ee83f971057d4ce1321d12c12390be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:27 +0000 Subject: [PATCH 14/19] Update dependency lcms2 to v2.19 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..cf4990c67 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -5,7 +5,7 @@ "fribidi": "1.0.16", "harfbuzz": "13.2.1", "jpegturbo": "3.1.4.1", - "lcms2": "2.18", + "lcms2": "2.19", "libavif": "1.4.1", "libimagequant": "4.4.1", "libpng": "1.6.56", From 32b6c5f0eee19ccb5e255e1edfdf7fd8833edfa7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:32 +0000 Subject: [PATCH 15/19] Update dependency harfbuzz to v14 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..c21c60e9e 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -3,7 +3,7 @@ "bzip2": "1.0.8", "freetype": "2.14.3", "fribidi": "1.0.16", - "harfbuzz": "13.2.1", + "harfbuzz": "14.2.0", "jpegturbo": "3.1.4.1", "lcms2": "2.18", "libavif": "1.4.1", From 575b33d811b8f50577fb8465d0f59e7bca4e5d95 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:29:50 +0000 Subject: [PATCH 16/19] Update dependency mypy to v1.20.2 --- .ci/requirements-mypy.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index c64343a73..ad78bcb67 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.19.1 +mypy==1.20.2 arro3-compute arro3-core IceSpringPySideStubs-PyQt6 From c234720acad29ff0ccd10b1564c0cdaae1d0fade Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 1 May 2026 11:40:49 +1000 Subject: [PATCH 17/19] Convert Exif to dictionary before checking --- Tests/test_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 32c799195..81bd47299 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -862,7 +862,7 @@ class TestImage: def test_exif_webp(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper.webp") as im: exif = im.getexif() - assert exif == {} + assert dict(exif) == {} out = tmp_path / "temp.webp" exif[258] = 8 @@ -884,7 +884,7 @@ class TestImage: def test_exif_png(self, tmp_path: Path) -> None: with Image.open("Tests/images/exif.png") as im: exif = im.getexif() - assert exif == {274: 1} + assert dict(exif) == {274: 1} out = tmp_path / "temp.png" exif[258] = 8 From 21790fc0da70f8dc9594248a1e30654c3cc05e65 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 1 May 2026 11:42:27 +1000 Subject: [PATCH 18/19] Check if sys.stdout is a TextIOWrapper instance --- src/PIL/Image.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 574980771..81add2f7a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2639,11 +2639,8 @@ class Image: if is_path(fp): filename = os.fspath(fp) open_fp = True - elif fp == sys.stdout: - try: - fp = sys.stdout.buffer - except AttributeError: - pass + elif fp == sys.stdout and isinstance(sys.stdout, io.TextIOWrapper): + fp = sys.stdout.buffer if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes filename = os.fspath(fp.name) From 4bba24632f13d2427ad7f52bf3402e35fe220b5f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 3 May 2026 22:13:11 +1000 Subject: [PATCH 19/19] Update docs --- docs/installation/building-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 79d54145a..ecb892e1f 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -51,7 +51,7 @@ Many of Pillow's features require external libraries: * **littlecms** provides color management * Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and - above uses liblcms2. Tested with **1.19** and **2.7-2.18**. + above uses liblcms2. Tested with **1.19** and **2.7-2.19**. * **libwebp** provides the WebP format.