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 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 diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..85db4ca0d 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -3,12 +3,12 @@ "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", + "lcms2": "2.19", "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", diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index 9e65121e6..3b15a7d91 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, @@ -79,18 +85,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 +113,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,61 +242,99 @@ 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", + "version": versions["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", + "version": versions["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", + "version": versions["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", + "version": versions["libavif"], + "scope": "optional", + "description": "AVIF codec (optional, used by PIL._avif).", + "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", + "version": versions["libimagequant"], + "scope": "optional", + "description": "Improved colour quantization (optional).", + "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", "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"}}, @@ -259,24 +348,13 @@ 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", "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/"}, @@ -286,42 +364,11 @@ 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", "name": "libwebp", + "version": versions["libwebp"], "scope": "optional", "description": "WebP codec (optional, used by PIL._webp).", "licenses": [{"license": {"id": "BSD-3-Clause"}}], @@ -336,90 +383,11 @@ 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", "name": "libxcb", + "version": versions["libxcb"], "scope": "optional", "description": "X11 screen-grab support (optional, " "used by PIL._imaging on macOS and Linux).", @@ -432,6 +400,38 @@ 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).", + "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", + "version": versions["openjpeg"], + "scope": "optional", + "description": "JPEG 2000 codec (optional).", + "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,6 +447,19 @@ 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"}}], + "externalReferences": [ + {"type": "website", "url": "https://zlib.net"}, + {"type": "distribution", "url": "https://zlib.net"}, + ], + }, ] dependencies = [ @@ -455,43 +468,43 @@ def generate(version: str) -> dict: "dependsOn": [e["bom-ref"] for e in ext_components], }, { - "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/libxcb", - ], + "ref": f"{purl}#c-ext/PIL._avif", + "dependsOn": ["pkg:generic/libavif"], }, { - "ref": f"{purl}#c-ext/PIL._imagingft", + "ref": f"{purl}#c-ext/PIL._imaging", "dependsOn": [ - "pkg:generic/freetype2", - f"{purl}#thirdparty/raqm", - f"{purl}#thirdparty/fribidi-shim", - "pkg:generic/harfbuzz", - "pkg:generic/fribidi", + "pkg:generic/libimagequant", + "pkg:generic/libjpeg", + "pkg:generic/libtiff", + "pkg:generic/libxcb", + "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", ], }, ] 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/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/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 98733b6c7..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 }} @@ -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') 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 diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 8fe2a5eac..734bcde92 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/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 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/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. 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 | 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) 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 diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 76a15bd0d..3f21fa48e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1443,35 +1443,47 @@ 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 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)) - 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]) + 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. + 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( 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);