diff --git a/.ci/install.sh b/.ci/install.sh index aeb5e6514..9553eb8f4 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -53,7 +53,7 @@ pushd depends && ./install_imagequant.sh && popd pushd depends && sudo ./install_raqm.sh && popd # libavif -pushd depends && sudo ./install_libavif.sh && popd +pushd depends && ./install_libavif.sh && popd # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 44af3e3df..857881c01 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -48,6 +48,13 @@ jobs: - name: Build system information run: python3 .github/workflows/system-info.py + - name: Cache libavif + uses: actions/cache@v5 + id: cache-libavif + with: + path: ~/cache-libavif + key: ${{ runner.os }}-libavif-${{ hashFiles('depends/install_libavif.sh', 'depends/libavif-svt4.patch') }} + - name: Cache libimagequant uses: actions/cache@v5 id: cache-libimagequant @@ -55,12 +62,21 @@ jobs: path: ~/cache-libimagequant key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }} + - name: Cache libwebp + uses: actions/cache@v5 + id: cache-libwebp + with: + path: ~/cache-libwebp + key: ${{ runner.os }}-libwebp-${{ hashFiles('depends/install_webp.sh') }} + - name: Install Linux dependencies run: | .ci/install.sh env: GHA_PYTHON_VERSION: "3.x" + GHA_LIBAVIF_CACHE_HIT: ${{ steps.cache-libavif.outputs.cache-hit }} GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }} + GHA_LIBWEBP_CACHE_HIT: ${{ steps.cache-libwebp.outputs.cache-hit }} - name: Build run: | diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 091edb222..8f24bef3d 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -83,7 +83,7 @@ jobs: - name: Docker pull run: | - docker pull pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} + docker pull ${{ matrix.qemu-arch && format('--platform=linux/{0}', matrix.qemu-arch)}} pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} - name: Docker build run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a206e269..80bbfb45f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,7 +61,7 @@ jobs: - { python-version: "3.14t", disable-gil: true } - { python-version: "3.13t", disable-gil: true } # Intel - - { os: "macos-15-intel", python-version: "3.10" } + - { os: "macos-26-intel", python-version: "3.10" } exclude: - { os: "macos-latest", python-version: "3.10" } @@ -91,6 +91,14 @@ jobs: - name: Build system information run: python3 .github/workflows/system-info.py + - name: Cache libavif + if: startsWith(matrix.os, 'ubuntu') + uses: actions/cache@v5 + id: cache-libavif + with: + path: ~/cache-libavif + key: ${{ runner.os }}-libavif-${{ hashFiles('depends/install_libavif.sh', 'depends/libavif-svt4.patch') }} + - name: Cache libimagequant if: startsWith(matrix.os, 'ubuntu') uses: actions/cache@v5 @@ -99,13 +107,23 @@ jobs: path: ~/cache-libimagequant key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }} + - name: Cache libwebp + if: startsWith(matrix.os, 'ubuntu') + uses: actions/cache@v5 + id: cache-libwebp + with: + path: ~/cache-libwebp + key: ${{ runner.os }}-libwebp-${{ hashFiles('depends/install_webp.sh') }} + - name: Install Linux dependencies if: startsWith(matrix.os, 'ubuntu') run: | .ci/install.sh env: GHA_PYTHON_VERSION: ${{ matrix.python-version }} + GHA_LIBAVIF_CACHE_HIT: ${{ steps.cache-libavif.outputs.cache-hit }} GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }} + GHA_LIBWEBP_CACHE_HIT: ${{ steps.cache-libwebp.outputs.cache-hit }} - name: Install macOS dependencies if: startsWith(matrix.os, 'macOS') diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index a9b779e81..0057f62d6 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -96,7 +96,7 @@ else FREETYPE_VERSION=2.14.1 fi HARFBUZZ_VERSION=12.3.2 -LIBPNG_VERSION=1.6.54 +LIBPNG_VERSION=1.6.55 JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 XZ_VERSION=5.8.2 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 20379c753..ed49f15c0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -53,19 +53,19 @@ jobs: include: - name: "macOS 10.10 x86_64" platform: macos - os: macos-15-intel + os: macos-26-intel cibw_arch: x86_64 build: "cp3{10,11}*" macosx_deployment_target: "10.10" - name: "macOS 10.13 x86_64" platform: macos - os: macos-15-intel + os: macos-26-intel cibw_arch: x86_64 build: "cp3{12,13}*" macosx_deployment_target: "10.13" - name: "macOS 10.15 x86_64" platform: macos - os: macos-15-intel + os: macos-26-intel cibw_arch: x86_64 build: "{cp314,pp3}*" macosx_deployment_target: "10.15" @@ -104,7 +104,7 @@ jobs: cibw_arch: arm64_iphonesimulator - name: "iOS x86_64 simulator" platform: ios - os: macos-15-intel + os: macos-26-intel cibw_arch: x86_64_iphonesimulator steps: - uses: actions/checkout@v6 @@ -250,7 +250,7 @@ jobs: runs-on: ubuntu-latest name: Count dists steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: pattern: dist-* path: dist @@ -269,7 +269,7 @@ jobs: runs-on: ubuntu-latest name: Upload wheels to scientific-python-nightly-wheels steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: pattern: dist-!(sdist)* path: dist @@ -291,7 +291,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: pattern: dist-* path: dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7eb69d164..53fd0a3ca 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.14.14 + rev: v0.15.4 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] @@ -11,7 +11,7 @@ repos: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.9.3 + rev: 1.9.4 hooks: - id: bandit args: [--severity-level=high] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v21.1.8 + rev: v22.1.0 hooks: - id: clang-format types: [c] @@ -38,6 +38,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: + - id: check-case-conflict - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - id: check-merge-conflict @@ -51,7 +52,7 @@ repos: exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.36.1 + rev: 0.37.0 hooks: - id: check-github-workflows - id: check-readthedocs @@ -68,12 +69,12 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.12.1 + rev: v2.16.2 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.24.1 + rev: v0.25 hooks: - id: validate-pyproject additional_dependencies: [trove-classifiers>=2024.10.12] diff --git a/Tests/images/pal8rletrns.png b/Tests/images/pal8rletrns.png new file mode 100644 index 000000000..2362266ef Binary files /dev/null and b/Tests/images/pal8rletrns.png differ diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 28e863459..2e0394b3b 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -221,6 +221,11 @@ def test_rle8_eof(file_name: str, length: int) -> None: im.load() +def test_rle_delta() -> None: + with Image.open("Tests/images/bmp/q/pal8rletrns.bmp") as im: + assert_image_equal_tofile(im, "Tests/images/pal8rletrns.png") + + def test_unsupported_bmp_bitfields_layout() -> None: fp = io.BytesIO( o32(40) # header size diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index e3fcec490..b52816fdc 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -310,6 +310,14 @@ def test_roundtrip_save_all_1(tmp_path: Path) -> None: assert reloaded.getpixel((0, 0)) == 255 +@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0))) +def test_save_zero(size: tuple[int, int]) -> None: + b = BytesIO() + im = Image.new("RGB", size) + with pytest.raises(SystemError): + im.save(b, "GIF") + + @pytest.mark.parametrize( "path, mode", ( diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 90740ab57..509d93469 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -37,6 +37,14 @@ def test_sanity(tmp_path: Path) -> None: im.save(f) +@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0))) +def test_save_zero(size: tuple[int, int]) -> None: + b = io.BytesIO() + im = Image.new("1", size) + with pytest.raises(ValueError): + im.save(b, "PCX") + + def test_p_4_planes() -> None: with Image.open("Tests/images/p_4_planes.pcx") as im: assert im.getpixel((0, 0)) == 3 diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 03494523b..903632cff 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -68,6 +68,14 @@ def test_save(tmp_path: Path) -> None: assert im2.format == "SPIDER" +@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0))) +def test_save_zero(size: tuple[int, int]) -> None: + b = BytesIO() + im = Image.new("1", size) + with pytest.raises(SystemError): + im.save(b, "SPIDER") + + def test_tempfile() -> None: # Arrange im = hopper() diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 10b89a2c0..526beb656 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -1,6 +1,6 @@ from __future__ import annotations -from io import BytesIO +import io from pathlib import Path import pytest @@ -23,6 +23,13 @@ def test_reload() -> None: assert_image_equal(im.convert("RGB"), original.convert("RGB")) +def test_save_fp() -> None: + palette = ImagePalette.ImagePalette() + with io.StringIO() as fp: + palette.save(fp) + assert not fp.closed + + def test_getcolor() -> None: palette = ImagePalette.ImagePalette() assert len(palette.palette) == 0 @@ -204,7 +211,7 @@ def test_2bit_palette(tmp_path: Path) -> None: def test_getpalette() -> None: - b = BytesIO(b"0 1\n1 2 3 4") + b = io.BytesIO(b"0 1\n1 2 3 4") p = PaletteFile.PaletteFile(b) palette, rawmode = p.getpalette() @@ -216,6 +223,6 @@ def test_invalid_palette() -> None: with pytest.raises(OSError): ImagePalette.load("Tests/images/hopper.jpg") - b = BytesIO(b"1" * 101) + b = io.BytesIO(b"1" * 101) with pytest.raises(SyntaxError, match="bad palette file"): PaletteFile.PaletteFile(b) diff --git a/depends/install_libavif.sh b/depends/install_libavif.sh index a6686f3ef..0089bf2b5 100755 --- a/depends/install_libavif.sh +++ b/depends/install_libavif.sh @@ -3,66 +3,88 @@ set -eo pipefail version=1.3.0 -./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz +if [[ "$GHA_LIBAVIF_CACHE_HIT" == "true" ]]; then -pushd libavif-$version + LIBDIR=/usr/lib/x86_64-linux-gnu -# Apply patch for SVT-AV1 4.0 compatibility -# Pending release of https://github.com/AOMediaCodec/libavif/pull/2971 -patch -p1 < ../libavif-svt4.patch + # Copy cached files into place + sudo cp ~/cache-libavif/lib/* $LIBDIR/ + sudo cp -r ~/cache-libavif/include/avif /usr/include/ -if [ $(uname) == "Darwin" ] && [ -x "$(command -v brew)" ]; then - PREFIX=$(brew --prefix) else - PREFIX=/usr + + ./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz + + pushd libavif-$version + + # Apply patch for SVT-AV1 4.0 compatibility + # Pending release of https://github.com/AOMediaCodec/libavif/pull/2971 + patch -p1 < ../libavif-svt4.patch + + if [ $(uname) == "Darwin" ] && [ -x "$(command -v brew)" ]; then + PREFIX=$(brew --prefix) + else + PREFIX=/usr + fi + + PKGCONFIG=${PKGCONFIG:-pkg-config} + + LIBAVIF_CMAKE_FLAGS=() + HAS_DECODER=0 + HAS_ENCODER=0 + + if $PKGCONFIG --exists aom; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=SYSTEM) + HAS_ENCODER=1 + HAS_DECODER=1 + fi + + if $PKGCONFIG --exists dav1d; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_DAV1D=SYSTEM) + HAS_DECODER=1 + fi + + if $PKGCONFIG --exists libgav1; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_LIBGAV1=SYSTEM) + HAS_DECODER=1 + fi + + if $PKGCONFIG --exists rav1e; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_RAV1E=SYSTEM) + HAS_ENCODER=1 + fi + + if $PKGCONFIG --exists SvtAv1Enc; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_SVT=SYSTEM) + HAS_ENCODER=1 + fi + + if [ "$HAS_ENCODER" != 1 ] || [ "$HAS_DECODER" != 1 ]; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=LOCAL) + fi + + cmake \ + -DCMAKE_INSTALL_PREFIX=$PREFIX \ + -DCMAKE_INSTALL_NAME_DIR=$PREFIX/lib \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_MACOSX_RPATH=OFF \ + -DAVIF_LIBSHARPYUV=LOCAL \ + -DAVIF_LIBYUV=LOCAL \ + "${LIBAVIF_CMAKE_FLAGS[@]}" \ + . + + sudo make install + + if [ -n "$GITHUB_ACTIONS" ] && [ "$(uname)" != "Darwin" ]; then + # Copy to cache + LIBDIR=/usr/lib/x86_64-linux-gnu + rm -rf ~/cache-libavif + mkdir -p ~/cache-libavif/lib + mkdir -p ~/cache-libavif/include + cp $LIBDIR/libavif.so* ~/cache-libavif/lib/ + cp -r /usr/include/avif ~/cache-libavif/include/ + fi + + popd + fi - -PKGCONFIG=${PKGCONFIG:-pkg-config} - -LIBAVIF_CMAKE_FLAGS=() -HAS_DECODER=0 -HAS_ENCODER=0 - -if $PKGCONFIG --exists aom; then - LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=SYSTEM) - HAS_ENCODER=1 - HAS_DECODER=1 -fi - -if $PKGCONFIG --exists dav1d; then - LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_DAV1D=SYSTEM) - HAS_DECODER=1 -fi - -if $PKGCONFIG --exists libgav1; then - LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_LIBGAV1=SYSTEM) - HAS_DECODER=1 -fi - -if $PKGCONFIG --exists rav1e; then - LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_RAV1E=SYSTEM) - HAS_ENCODER=1 -fi - -if $PKGCONFIG --exists SvtAv1Enc; then - LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_SVT=SYSTEM) - HAS_ENCODER=1 -fi - -if [ "$HAS_ENCODER" != 1 ] || [ "$HAS_DECODER" != 1 ]; then - LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=LOCAL) -fi - -cmake \ - -DCMAKE_INSTALL_PREFIX=$PREFIX \ - -DCMAKE_INSTALL_NAME_DIR=$PREFIX/lib \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_MACOSX_RPATH=OFF \ - -DAVIF_LIBSHARPYUV=LOCAL \ - -DAVIF_LIBYUV=LOCAL \ - "${LIBAVIF_CMAKE_FLAGS[@]}" \ - . - -make install - -popd diff --git a/depends/install_webp.sh b/depends/install_webp.sh index d7f3cd2f5..c328fe2c8 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -3,10 +3,30 @@ archive=libwebp-1.6.0 -./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz +if [[ "$GHA_LIBWEBP_CACHE_HIT" == "true" ]]; then -pushd $archive + # Copy cached files into place + sudo cp ~/cache-libwebp/lib/* /usr/lib/ + sudo cp -r ~/cache-libwebp/include/webp /usr/include/ -./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install +else -popd + ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz + + pushd $archive + + ./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install + + if [ -n "$GITHUB_ACTIONS" ]; then + # Copy to cache + rm -rf ~/cache-libwebp + mkdir -p ~/cache-libwebp/lib + mkdir -p ~/cache-libwebp/include + cp /usr/lib/libwebp*.so* /usr/lib/libwebp*.a ~/cache-libwebp/lib/ + cp /usr/lib/libsharpyuv.so* /usr/lib/libsharpyuv.a ~/cache-libwebp/lib/ + cp -r /usr/include/webp ~/cache-libwebp/include/ + fi + + popd + +fi diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 35ec99ece..a9fd764e6 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -828,16 +828,6 @@ PCX Pillow reads and writes PCX files containing ``1``, ``L``, ``P``, or ``RGB`` data. -PFM -^^^ - -.. versionadded:: 10.3.0 - -Pillow reads and writes grayscale (Pf format) Portable FloatMap (PFM) files -containing ``F`` data. - -Color (PF format) PFM files are not supported. - Opening ~~~~~~~ @@ -1081,12 +1071,19 @@ following parameters can also be set: PPM ^^^ -Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L``, ``I`` or -``RGB`` data. +Pillow reads and writes PBM, PGM, PPM, PNM and PFM files containing ``1``, ``L``, ``I``, +``RGB`` or ``F`` data. "Raw" (P4 to P6) formats can be read, and are used when writing. -Since Pillow 9.2.0, "plain" (P1 to P3) formats can be read as well. +.. versionadded:: 9.2.0 + "Plain" (P1 to P3) formats can be read. + +.. versionadded:: 10.3.0 + Grayscale (Pf format) Portable FloatMap (PFM) files containing + ``F`` data can be read and used when writing. + +Color (PF format) PFM files are not supported. QOI ^^^ diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 7a8707b9a..74c63fb06 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -39,15 +39,15 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Gentoo | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| macOS 15 Sequoia | 3.10 | x86-64 | -| +----------------------------+---------------------+ -| | 3.11, 3.12, 3.13, 3.14, | arm64 | -| | PyPy3 | | +| macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 | +| | 3.15, PyPy3 | | ++----------------------------------+----------------------------+---------------------+ +| macOS 26 Tahoe | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 | -| | 3.14, PyPy3 | | +| | 3.14, 3.15, PyPy3 | | | +----------------------------+---------------------+ | | 3.12 | arm64v8, ppc64le, | | | | s390x | @@ -55,7 +55,7 @@ These platforms are built and tested for every change. | Windows Server 2022 | 3.10 | x86 | +----------------------------------+----------------------------+---------------------+ | Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | -| | PyPy3 | | +| | 3.15, PyPy3 | | | +----------------------------+---------------------+ | | 3.13 (MinGW) | x86-64 | +----------------------------------+----------------------------+---------------------+ diff --git a/pyproject.toml b/pyproject.toml index 91f4750e4..6d9910ca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,6 @@ optional-dependencies.test-arrow = [ "nanoarrow", "pyarrow", ] - optional-dependencies.tests = [ "check-manifest", "coverage>=7.4.2", @@ -77,16 +76,15 @@ optional-dependencies.tests = [ "pytest-xdist", "trove-classifiers>=2024.10.12", ] - optional-dependencies.xmp = [ "defusedxml", ] +urls."Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" urls.Changelog = "https://github.com/python-pillow/Pillow/releases" urls.Documentation = "https://pillow.readthedocs.io" urls.Funding = "https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi" urls.Homepage = "https://python-pillow.github.io" urls.Mastodon = "https://fosstodon.org/@pillow" -urls."Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" urls.Source = "https://github.com/python-pillow/Pillow" [tool.setuptools] @@ -95,70 +93,50 @@ packages = [ ] include-package-data = true package-dir = { "" = "src" } - -[tool.setuptools.dynamic] -version = { attr = "PIL.__version__" } +dynamic.version = { attr = "PIL.__version__" } [tool.cibuildwheel] before-all = ".github/workflows/wheels-dependencies.sh" build-verbosity = 1 - config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable" - test-command = "cd {project} && .github/workflows/wheels-test.sh" test-extras = "tests" test-requires = [ "numpy", ] -xbuild-tools = [ ] - -[tool.cibuildwheel.ios] +xbuild-tools = [] # Disable platform guessing on iOS, and disable raqm (since there won't be a # vendor version, and we can't distribute it due to licensing) -config-settings = "raqm=disable imagequant=disable platform-guessing=disable" - +ios.config-settings = "raqm=disable imagequant=disable platform-guessing=disable" # iOS needs to be given a specific pytest invocation and list of test sources. -test-sources = [ +ios.test-sources = [ "checks", "Tests", "selftest.py", ] -test-command = [ +ios.test-command = [ "python -m selftest", "python -m pytest -vv -x -W always checks/check_wheel.py Tests", ] - # There's no numpy wheel for iOS (yet...) -test-requires = [ ] - -[tool.cibuildwheel.macos] +ios.test-requires = [] # Disable platform guessing on macOS to avoid picking up Homebrew etc. -config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable platform-guessing=disable" - -[tool.cibuildwheel.macos.environment] +macos.config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable platform-guessing=disable" # Isolate macOS build environment from Homebrew etc. -PATH = "$(pwd)/build/deps/darwin/bin:$(dirname $(which python3)):/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin" - -[[tool.cibuildwheel.overrides]] -# iOS environment is isolated by cibuildwheel, but needs the dependencies -select = "*_iphoneos" -environment.PATH = "$(pwd)/build/deps/iphoneos/bin:$PATH" - -[[tool.cibuildwheel.overrides]] -# iOS simulator environment is isolated by cibuildwheel, but needs the dependencies -select = "*_iphonesimulator" -environment.PATH = "$(pwd)/build/deps/iphonesimulator/bin:$PATH" - -[[tool.cibuildwheel.overrides]] -select = "*-win32" -test-requires = [ ] +macos.environment.PATH = "$(pwd)/build/deps/darwin/bin:$(dirname $(which python3)):/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin" +overrides = [ + # iOS environment is isolated by cibuildwheel, but needs the dependencies + { select = "*_iphoneos", environment.PATH = "$(pwd)/build/deps/iphoneos/bin:$PATH" }, + # iOS simulator environment is isolated by cibuildwheel, but needs the dependencies + { select = "*_iphonesimulator", environment.PATH = "$(pwd)/build/deps/iphonesimulator/bin:$PATH" }, + { select = "*-win32", test-requires = [] }, +] [tool.black] exclude = "wheels/multibuild" [tool.ruff] exclude = [ "wheels/multibuild" ] - fix = true lint.select = [ "C4", # flake8-comprehensions @@ -207,8 +185,8 @@ lint.isort.required-imports = [ [tool.pyproject-fmt] max_supported_python = "3.14" -[tool.pytest.ini_options] -addopts = "-ra --color=auto" +[tool.pytest] +addopts = [ "-ra", "--color=auto" ] testpaths = [ "Tests", ] diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index a12271370..5ee61b35b 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -369,7 +369,7 @@ class BmpRleDecoder(ImageFile.PyDecoder): bytes_read = self.fd.read(2) if len(bytes_read) < 2: break - right, up = self.fd.read(2) + right, up = bytes_read data += b"\x00" * (right + up * self.state.xsize) x = len(data) % self.state.xsize else: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 76a0d4ab9..390b3b374 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -937,7 +937,13 @@ def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: :param info: encoderinfo :returns: list of indexes of palette entries in use, or None """ - if im.mode in ("P", "L") and info and info.get("optimize"): + if ( + im.mode in ("P", "L") + and info + and info.get("optimize") + and im.width != 0 + and im.height != 0 + ): # Potentially expensive operation. # The palette saves 3 bytes per color not used, but palette diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 341435437..50e0075a2 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -579,10 +579,7 @@ class Parser: pass # not enough data else: flag = hasattr(im, "load_seek") or hasattr(im, "load_read") - if flag or len(im.tile) != 1: - # custom load code, or multiple tiles - self.decode = None - else: + if not flag and len(im.tile) == 1: # initialize decoder im.load_prepare() d, e, o, a = im.tile[0] diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index eae7aea8f..99ad2771b 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -191,19 +191,24 @@ class ImagePalette: if self.rawmode: msg = "palette contains raw palette data" raise ValueError(msg) + open_fp = False if isinstance(fp, str): fp = open(fp, "w") - fp.write("# Palette\n") - fp.write(f"# Mode: {self.mode}\n") - for i in range(256): - fp.write(f"{i}") - for j in range(i * len(self.mode), (i + 1) * len(self.mode)): - try: - fp.write(f" {self.palette[j]}") - except IndexError: - fp.write(" 0") - fp.write("\n") - fp.close() + open_fp = True + try: + fp.write("# Palette\n") + fp.write(f"# Mode: {self.mode}\n") + for i in range(256): + fp.write(f"{i}") + for j in range(i * len(self.mode), (i + 1) * len(self.mode)): + try: + fp.write(f" {self.palette[j]}") + except IndexError: + fp.write(" 0") + fp.write("\n") + finally: + if open_fp: + fp.close() # -------------------------------------------------------------------- diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 9360061ba..bee0a56f9 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -59,11 +59,10 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + b"MPF\0" + b" " * ifd_length ) - exif = im_frame.encoderinfo.get("exif") - if isinstance(exif, Image.Exif): - exif = exif.tobytes() - im_frame.encoderinfo["exif"] = exif - if exif: + if exif := im_frame.encoderinfo.get("exif"): + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + im_frame.encoderinfo["exif"] = exif mpf_offset += 4 + len(exif) JpegImagePlugin._save(im_frame, fp, filename) diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 6b16d5385..3e34e3c63 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -146,6 +146,10 @@ SAVE = { def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.width == 0 or im.height == 0: + msg = "Cannot write empty image as PCX" + raise ValueError(msg) + try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9826a4cd1..572762e6c 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1403,8 +1403,7 @@ def _save( chunks = [b"cHRM", b"cICP", b"gAMA", b"sBIT", b"sRGB", b"tIME"] - icc = im.encoderinfo.get("icc_profile", im.info.get("icc_profile")) - if icc: + if icc := im.encoderinfo.get("icc_profile", im.info.get("icc_profile")): # ICC profile # according to PNG spec, the iCCP chunk contains: # Profile name 1-79 bytes (character string) @@ -1419,8 +1418,7 @@ def _save( # Disallow sRGB chunks when an iCCP-chunk has been emitted. chunks.remove(b"sRGB") - info = im.encoderinfo.get("pnginfo") - if info: + if info := im.encoderinfo.get("pnginfo"): chunks_multiple_allowed = [b"sPLT", b"iTXt", b"tEXt", b"zTXt"] for info_chunk in info.chunks: cid, data = info_chunk[:2] @@ -1472,8 +1470,7 @@ def _save( alpha_bytes = colors chunk(fp, b"tRNS", alpha[:alpha_bytes]) - dpi = im.encoderinfo.get("dpi") - if dpi: + if dpi := im.encoderinfo.get("dpi"): chunk( fp, b"pHYs", @@ -1490,8 +1487,7 @@ def _save( chunks.remove(cid) chunk(fp, cid, data) - exif = im.encoderinfo.get("exif") - if exif: + if exif := im.encoderinfo.get("exif"): if isinstance(exif, Image.Exif): exif = exif.tobytes(8) if exif.startswith(b"Exif\x00\x00"): diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 848dccda5..11d90699d 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -244,7 +244,7 @@ def loadImageSeries(filelist: list[str] | None = None) -> list[Image.Image] | No def makeSpiderHeader(im: Image.Image) -> list[bytes]: nsam, nrow = im.size - lenbyt = nsam * 4 # There are labrec records in the header + lenbyt = max(1, nsam) * 4 # There are labrec records in the header labrec = int(1024 / lenbyt) if 1024 % lenbyt != 0: labrec += 1 diff --git a/src/Tk/tkImaging.c b/src/Tk/tkImaging.c index 834634bd7..3e35f885f 100644 --- a/src/Tk/tkImaging.c +++ b/src/Tk/tkImaging.c @@ -124,8 +124,10 @@ PyImagingPhotoPut( if (im->mode == IMAGING_MODE_1 || im->mode == IMAGING_MODE_L) { block.pixelSize = 1; block.offset[0] = block.offset[1] = block.offset[2] = block.offset[3] = 0; - } else if (im->mode == IMAGING_MODE_RGB || im->mode == IMAGING_MODE_RGBA || - im->mode == IMAGING_MODE_RGBX || im->mode == IMAGING_MODE_RGBa) { + } else if ( + im->mode == IMAGING_MODE_RGB || im->mode == IMAGING_MODE_RGBA || + im->mode == IMAGING_MODE_RGBX || im->mode == IMAGING_MODE_RGBa + ) { block.pixelSize = 4; block.offset[0] = 0; block.offset[1] = 1; diff --git a/src/_avif.c b/src/_avif.c index 3585297fe..5e8b9fe8e 100644 --- a/src/_avif.c +++ b/src/_avif.c @@ -485,7 +485,7 @@ _encoder_add(AvifEncoderObject *self, PyObject *args) { frame = image; } else { frame = avifImageCreateEmpty(); - if (image == NULL) { + if (frame == NULL) { PyErr_SetString(PyExc_ValueError, "Image creation failed"); return NULL; } diff --git a/src/encode.c b/src/encode.c index 06e4a0893..ea57615be 100644 --- a/src/encode.c +++ b/src/encode.c @@ -999,8 +999,9 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, PyBytes_AsString(value) ); - } else if (type == TIFF_DOUBLE || type == TIFF_SRATIONAL || - type == TIFF_RATIONAL) { + } else if ( + type == TIFF_DOUBLE || type == TIFF_SRATIONAL || type == TIFF_RATIONAL + ) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (FLOAT64)PyFloat_AsDouble(value) ); diff --git a/src/libImaging/Arrow.c b/src/libImaging/Arrow.c index d2ed10f0a..de4d3568e 100644 --- a/src/libImaging/Arrow.c +++ b/src/libImaging/Arrow.c @@ -170,16 +170,17 @@ export_named_type(struct ArrowSchema *schema, char *format, const char *name) { strncpy(formatp, format, format_len); strncpy(namep, name, name_len); - *schema = (struct ArrowSchema){// Type description - .format = formatp, - .name = namep, - .metadata = NULL, - .flags = 0, - .n_children = 0, - .children = NULL, - .dictionary = NULL, - // Bookkeeping - .release = &ReleaseExportedSchema + *schema = (struct ArrowSchema){ + // Type description + .format = formatp, + .name = namep, + .metadata = NULL, + .flags = 0, + .n_children = 0, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &ReleaseExportedSchema }; return 0; } @@ -287,17 +288,18 @@ export_single_channel_array(Imaging im, struct ArrowArray *array) { im->refcount++; MUTEX_UNLOCK(&im->mutex); // Initialize primitive fields - *array = (struct ArrowArray){// Data description - .length = length, - .offset = 0, - .null_count = 0, - .n_buffers = 2, - .n_children = 0, - .children = NULL, - .dictionary = NULL, - // Bookkeeping - .release = &release_const_array, - .private_data = im + *array = (struct ArrowArray){ + // Data description + .length = length, + .offset = 0, + .null_count = 0, + .n_buffers = 2, + .n_children = 0, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &release_const_array, + .private_data = im }; // Allocate list of buffers @@ -332,17 +334,18 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { // Initialize primitive fields // Fixed length arrays are 1 buffer of validity, and the length in pixels. // Data is in a child array. - *array = (struct ArrowArray){// Data description - .length = length, - .offset = 0, - .null_count = 0, - .n_buffers = 1, - .n_children = 1, - .children = NULL, - .dictionary = NULL, - // Bookkeeping - .release = &release_const_array, - .private_data = im + *array = (struct ArrowArray){ + // Data description + .length = length, + .offset = 0, + .null_count = 0, + .n_buffers = 1, + .n_children = 1, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &release_const_array, + .private_data = im }; // Allocate list of buffers @@ -367,17 +370,18 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { MUTEX_LOCK(&im->mutex); im->refcount++; MUTEX_UNLOCK(&im->mutex); - *array->children[0] = (struct ArrowArray){// Data description - .length = length * 4, - .offset = 0, - .null_count = 0, - .n_buffers = 2, - .n_children = 0, - .children = NULL, - .dictionary = NULL, - // Bookkeeping - .release = &release_const_array, - .private_data = im + *array->children[0] = (struct ArrowArray){ + // Data description + .length = length * 4, + .offset = 0, + .null_count = 0, + .n_buffers = 2, + .n_children = 0, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &release_const_array, + .private_data = im }; array->children[0]->buffers = diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 330e5325c..002497c32 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1695,16 +1695,20 @@ ImagingConvertTransparent(Imaging imIn, const ModeID mode, int r, int g, int b) if (mode == IMAGING_MODE_RGBa) { premultiplied = 1; } - } else if (imIn->mode == IMAGING_MODE_RGB && - (mode == IMAGING_MODE_LA || mode == IMAGING_MODE_La)) { + } else if ( + imIn->mode == IMAGING_MODE_RGB && + (mode == IMAGING_MODE_LA || mode == IMAGING_MODE_La) + ) { convert = rgb2la; source_transparency = 1; if (mode == IMAGING_MODE_La) { premultiplied = 1; } - } else if ((imIn->mode == IMAGING_MODE_1 || imIn->mode == IMAGING_MODE_I || - imIn->mode == IMAGING_MODE_I_16 || imIn->mode == IMAGING_MODE_L) && - (mode == IMAGING_MODE_RGBA || mode == IMAGING_MODE_LA)) { + } else if ( + (imIn->mode == IMAGING_MODE_1 || imIn->mode == IMAGING_MODE_I || + imIn->mode == IMAGING_MODE_I_16 || imIn->mode == IMAGING_MODE_L) && + (mode == IMAGING_MODE_RGBA || mode == IMAGING_MODE_LA) + ) { if (imIn->mode == IMAGING_MODE_1) { convert = bit2rgb; } else if (imIn->mode == IMAGING_MODE_I) { diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index d28980432..0d28069f0 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -537,8 +537,9 @@ polygon_generic( // Needed to draw consistent polygons xx[j] = xx[j - 1]; j++; - } else if ((ymin == current->ymin || ymin == current->ymax) && - current->dx != 0) { + } else if ( + (ymin == current->ymin || ymin == current->ymax) && current->dx != 0 + ) { // Connect discontiguous corners for (k = 0; k < i; k++) { Edge *other_edge = edge_table[k]; @@ -570,8 +571,10 @@ polygon_generic( adjacent_line_x, adjacent_line_x_other_edge )) + 1; - } else if (xx[j - 1] < adjacent_line_x - 1 && - xx[j - 1] < adjacent_line_x_other_edge - 1) { + } else if ( + xx[j - 1] < adjacent_line_x - 1 && + xx[j - 1] < adjacent_line_x_other_edge - 1 + ) { xx[j - 1] = roundf(fmin( adjacent_line_x, adjacent_line_x_other_edge diff --git a/src/libImaging/GetBBox.c b/src/libImaging/GetBBox.c index d336121d5..7a57f6894 100644 --- a/src/libImaging/GetBBox.c +++ b/src/libImaging/GetBBox.c @@ -89,10 +89,12 @@ ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) { INT32 mask = 0xffffffff; if (im->bands == 3) { ((UINT8 *)&mask)[3] = 0; - } else if (alpha_only && - (im->mode == IMAGING_MODE_RGBa || im->mode == IMAGING_MODE_RGBA || - im->mode == IMAGING_MODE_La || im->mode == IMAGING_MODE_LA || - im->mode == IMAGING_MODE_PA)) { + } else if ( + alpha_only && + (im->mode == IMAGING_MODE_RGBa || im->mode == IMAGING_MODE_RGBA || + im->mode == IMAGING_MODE_La || im->mode == IMAGING_MODE_LA || + im->mode == IMAGING_MODE_PA) + ) { #ifdef WORDS_BIGENDIAN mask = 0x000000ff; #else diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index fdfbde2d7..3012783a2 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -330,8 +330,10 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { components = 4; color_space = OPJ_CLRSPC_SRGB; pack = j2k_pack_rgba; -#if ((OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR == 5 && OPJ_VERSION_BUILD >= 3) || \ - (OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR > 5) || OPJ_VERSION_MAJOR > 2) +#if ( \ + (OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR == 5 && OPJ_VERSION_BUILD >= 3) || \ + (OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR > 5) || OPJ_VERSION_MAJOR > 2 \ +) } else if (im->mode == IMAGING_MODE_CMYK) { components = 4; color_space = OPJ_CLRSPC_CMYK; diff --git a/src/libImaging/JpegDecode.c b/src/libImaging/JpegDecode.c index ae3274456..05cb37554 100644 --- a/src/libImaging/JpegDecode.c +++ b/src/libImaging/JpegDecode.c @@ -206,8 +206,10 @@ ImagingJpegDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t by context->cinfo.out_color_space = JCS_EXT_RGBX; } #endif - else if (context->rawmode == IMAGING_RAWMODE_CMYK || - context->rawmode == IMAGING_RAWMODE_CMYK_I) { + else if ( + context->rawmode == IMAGING_RAWMODE_CMYK || + context->rawmode == IMAGING_RAWMODE_CMYK_I + ) { context->cinfo.out_color_space = JCS_CMYK; } else if (context->rawmode == IMAGING_RAWMODE_YCbCr) { context->cinfo.out_color_space = JCS_YCbCr; diff --git a/src/libImaging/Matrix.c b/src/libImaging/Matrix.c index d28e04edf..acd59ba7f 100644 --- a/src/libImaging/Matrix.c +++ b/src/libImaging/Matrix.c @@ -46,8 +46,9 @@ ImagingConvertMatrix(Imaging im, const ModeID mode, float m[]) { } } ImagingSectionLeave(&cookie); - } else if (mode == IMAGING_MODE_HSV || mode == IMAGING_MODE_LAB || - mode == IMAGING_MODE_RGB) { + } else if ( + mode == IMAGING_MODE_HSV || mode == IMAGING_MODE_LAB || mode == IMAGING_MODE_RGB + ) { imOut = ImagingNewDirty(mode, im->xsize, im->ysize); if (!imOut) { return NULL; diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index bd9bd06b6..43c58d15f 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -121,7 +121,7 @@ V = { "LCMS2": "2.18", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.4.1", - "LIBPNG": "1.6.54", + "LIBPNG": "1.6.55", "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1",