diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 485866de6..6e869a5c2 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.3.0 +cibuildwheel==3.3.1 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 5b0e2eaf8..c64343a73 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.19.0 +mypy==1.19.1 arro3-compute arro3-core IceSpringPySideStubs-PyQt6 @@ -9,7 +9,6 @@ packaging pyarrow-stubs pybind11 pytest -sphinx types-atheris types-defusedxml types-olefile diff --git a/.github/renovate.json b/.github/renovate.json index 91fa0426f..8187fc15b 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,6 +6,7 @@ "labels": [ "Dependency" ], + "minimumReleaseAge": "7 days", "packageRules": [ { "groupName": "github-actions", diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 6a86b8aeb..7e771f1b7 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -44,13 +44,13 @@ jobs: language: python dry-run: false - name: Upload New Crash - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() && steps.build.outcome == 'success' with: name: artifacts path: ./out/artifacts - name: Upload Legacy Crash - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: steps.run.outcome == 'success' with: name: crash diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e88abf16f..44af3e3df 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,7 +49,7 @@ jobs: run: python3 .github/workflows/system-info.py - name: Cache libimagequant - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache-libimagequant with: path: ~/cache-libimagequant diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4f67be6f7..e2f8bf47a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.x" - name: Install uv uses: astral-sh/setup-uv@v7 - name: Lint diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index e864763da..ed32be26d 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -112,7 +112,7 @@ jobs: - name: Cache build id: build-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: winbuild\build key: @@ -188,8 +188,9 @@ jobs: # trim ~150MB for each job - name: Optimize build cache if: steps.build-cache.outputs.cache-hit != 'true' - run: rmdir /S /Q winbuild\build\src - shell: cmd + run: | + rm -rf winbuild\build\src + shell: bash - name: Build Pillow run: | @@ -206,9 +207,7 @@ jobs: - name: Test Pillow run: | - path %GITHUB_WORKSPACE%\winbuild\build\bin;%PATH% .ci\test.cmd - shell: cmd - name: Prepare to upload errors if: failure() @@ -217,7 +216,7 @@ jobs: shell: bash - name: Upload errors - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: errors diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da3eea066..3a206e269 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,7 @@ concurrency: env: COVERAGE_CORE: sysmon FORCE_COLOR: 1 + PIP_DISABLE_PIP_VERSION_CHECK: 1 jobs: build: @@ -92,7 +93,7 @@ jobs: - name: Cache libimagequant if: startsWith(matrix.os, 'ubuntu') - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache-libimagequant with: path: ~/cache-libimagequant @@ -143,7 +144,7 @@ jobs: mkdir -p Tests/errors - name: Upload errors - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: errors diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index e1586b7c5..a9b779e81 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -95,15 +95,15 @@ if [[ -n "$IOS_SDK" ]]; then else FREETYPE_VERSION=2.14.1 fi -HARFBUZZ_VERSION=12.3.0 -LIBPNG_VERSION=1.6.53 +HARFBUZZ_VERSION=12.3.2 +LIBPNG_VERSION=1.6.54 JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 XZ_VERSION=5.8.2 ZSTD_VERSION=1.5.7 TIFF_VERSION=4.7.1 -LCMS2_VERSION=2.17 -ZLIB_NG_VERSION=2.3.2 +LCMS2_VERSION=2.18 +ZLIB_NG_VERSION=2.3.3 LIBWEBP_VERSION=1.6.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 @@ -267,7 +267,7 @@ function build { build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto if [[ -n "$IS_MACOS" ]]; then - build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto + build_simple xorgproto 2025.1 https://www.x.org/pub/individual/proto build_simple libXau 1.0.12 https://www.x.org/pub/individual/lib build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist else diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fb71ead37..20379c753 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -134,7 +134,7 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: dist-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -186,24 +186,18 @@ jobs: - name: Build wheels run: | - setlocal EnableDelayedExpansion - for %%f in (winbuild\build\license\*) do ( - set x=%%~nf - rem Skip FriBiDi license, it is not included in the wheel. - set fribidi=!x:~0,7! - if NOT !fribidi!==fribidi ( - rem Skip imagequant license, it is not included in the wheel. - set libimagequant=!x:~0,13! - if NOT !libimagequant!==libimagequant ( - echo. >> LICENSE - echo ===== %%~nf ===== >> LICENSE - echo. >> LICENSE - type %%f >> LICENSE - ) - ) - ) - call winbuild\\build\\build_env.cmd - %pythonLocation%\python.exe -m cibuildwheel . --output-dir wheelhouse + for f in winbuild/build/license/*; do + name=$(basename "${f%.*}") + # Skip FriBiDi license, it is not included in the wheel. + [[ $name == fribidi* ]] && continue + # Skip imagequant license, it is not included in the wheel. + [[ $name == libimagequant* ]] && continue + echo "" >> LICENSE + echo "===== $name =====" >> LICENSE + echo "" >> LICENSE + cat "$f" >> LICENSE + done + cmd //c "winbuild\\build\\build_env.cmd && $pythonLocation\\python.exe -m cibuildwheel . --output-dir wheelhouse" env: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" @@ -217,16 +211,16 @@ jobs: -e CI -e GITHUB_ACTIONS mcr.microsoft.com/windows/servercore:ltsc2022 powershell C:\pillow\.github\workflows\wheels-test.ps1 %CD%\..\venv-test' - shell: cmd + shell: bash - name: Upload wheels - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: dist-windows-${{ matrix.cibw_arch }} path: ./wheelhouse/*.whl - name: Upload fribidi.dll - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: fribidi-windows-${{ matrix.cibw_arch }} path: winbuild\build\bin\fribidi* @@ -246,7 +240,7 @@ jobs: - run: make sdist - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: dist-sdist path: dist/*.tar.gz @@ -256,7 +250,7 @@ jobs: runs-on: ubuntu-latest name: Count dists steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: pattern: dist-* path: dist @@ -275,13 +269,13 @@ jobs: runs-on: ubuntu-latest name: Upload wheels to scientific-python-nightly-wheels steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: pattern: dist-!(sdist)* path: dist merge-multiple: true - name: Upload wheels to scientific-python-nightly-wheels - uses: scientific-python/upload-nightly-action@b36e8c0c10dbcfd2e05bf95f17ef8c14fd708dbf # 0.6.2 + uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3 with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} @@ -297,7 +291,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: pattern: dist-* path: dist diff --git a/.github/zizmor.yml b/.github/zizmor.yml index f4949c30c..100026562 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,7 +1,5 @@ # https://docs.zizmor.sh/configuration/ rules: - obfuscation: - disable: true unpinned-uses: config: policies: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10343f91a..7eb69d164 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,30 +1,30 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.7 + rev: v0.14.14 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.11.0 + rev: 26.1.0 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.9.2 + rev: 1.9.3 hooks: - id: bandit args: [--severity-level=high] files: ^src/ - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.5 + rev: v1.5.6 hooks: - id: remove-tabs exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v21.1.6 + rev: v21.1.8 hooks: - id: clang-format types: [c] @@ -51,14 +51,14 @@ repos: exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.35.0 + rev: 0.36.1 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.18.0 + rev: v1.22.0 hooks: - id: zizmor @@ -68,7 +68,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.11.1 + rev: v2.12.1 hooks: - id: pyproject-fmt @@ -76,10 +76,10 @@ repos: rev: v0.24.1 hooks: - id: validate-pyproject - additional_dependencies: [tomli, trove-classifiers>=2024.10.12] + additional_dependencies: [trove-classifiers>=2024.10.12] - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.7.0 + rev: 1.7.1 hooks: - id: tox-ini-fmt diff --git a/Tests/images/imagedraw_rounded_rectangle_radius.png b/Tests/images/imagedraw_rounded_rectangle_radius.png new file mode 100644 index 000000000..e2acf7be1 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_radius.png differ diff --git a/Tests/images/psd-oob-write-x.psd b/Tests/images/psd-oob-write-x.psd new file mode 100644 index 000000000..86359f4cb Binary files /dev/null and b/Tests/images/psd-oob-write-x.psd differ diff --git a/Tests/images/psd-oob-write-y.psd b/Tests/images/psd-oob-write-y.psd new file mode 100644 index 000000000..73498266a Binary files /dev/null and b/Tests/images/psd-oob-write-y.psd differ diff --git a/Tests/images/psd-oob-write.psd b/Tests/images/psd-oob-write.psd new file mode 100644 index 000000000..65a4472cf Binary files /dev/null and b/Tests/images/psd-oob-write.psd differ diff --git a/Tests/test_arro3.py b/Tests/test_arro3.py index 672eedc9b..42d032f76 100644 --- a/Tests/test_arro3.py +++ b/Tests/test_arro3.py @@ -213,7 +213,7 @@ INT32 = DataShape( ), ) def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None: - (dtype, elt, elts_per_pixel) = data_tp + dtype, elt, elts_per_pixel = data_tp ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1] if dtype == fl_uint8_4_type: @@ -239,7 +239,7 @@ def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> Non ) @pytest.mark.parametrize("data_tp", (UINT32, INT32)) def test_from_int32array(mode: str, mask: list[int] | None, data_tp: DataShape) -> None: - (dtype, elt, elts_per_pixel) = data_tp + dtype, elt, elts_per_pixel = data_tp ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1] arr = Array([elt] * (ct_pixels * elts_per_pixel), type=dtype) diff --git a/Tests/test_arrow.py b/Tests/test_arrow.py index b86c77b9a..9f84a75a4 100644 --- a/Tests/test_arrow.py +++ b/Tests/test_arrow.py @@ -68,7 +68,7 @@ def test_multiblock_l_image() -> None: img = Image.new("L", size, 128) with pytest.raises(ValueError): - (schema, arr) = img.__arrow_c_array__() + schema, arr = img.__arrow_c_array__() def test_multiblock_rgba_image() -> None: @@ -79,7 +79,7 @@ def test_multiblock_rgba_image() -> None: img = Image.new("RGBA", size, (128, 127, 126, 125)) with pytest.raises(ValueError): - (schema, arr) = img.__arrow_c_array__() + schema, arr = img.__arrow_c_array__() def test_multiblock_l_schema() -> None: @@ -114,7 +114,7 @@ def test_singleblock_l_image() -> None: img = Image.new("L", size, 128) assert img.im.isblock() - (schema, arr) = img.__arrow_c_array__() + schema, arr = img.__arrow_c_array__() assert schema assert arr @@ -130,7 +130,7 @@ def test_singleblock_rgba_image() -> None: img = Image.new("RGBA", size, (128, 127, 126, 125)) assert img.im.isblock() - (schema, arr) = img.__arrow_c_array__() + schema, arr = img.__arrow_c_array__() assert schema assert arr Image.core.set_use_block_allocator(0) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 2615f5a60..e3fcec490 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -399,7 +399,7 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: b = BytesIO() GifImagePlugin._save_netpbm(img_rgb, b, tempfile) with Image.open(tempfile) as reloaded: - assert_image_similar(img_rgb, reloaded.convert("RGB"), 0) + assert_image_equal(img_rgb, reloaded.convert("RGB")) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") @@ -411,7 +411,7 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: b = BytesIO() GifImagePlugin._save_netpbm(img_l, b, tempfile) with Image.open(tempfile) as reloaded: - assert_image_similar(img_l, reloaded.convert("L"), 0) + assert_image_equal(img_l, reloaded.convert("L")) def test_seek() -> None: @@ -1433,7 +1433,7 @@ def test_getdata(monkeypatch: pytest.MonkeyPatch) -> None: # with open('Tests/images/gif_header_data.pkl', 'wb') as f: # pickle.dump((h, d), f, 1) with open("Tests/images/gif_header_data.pkl", "rb") as f: - (h_target, d_target) = pickle.load(f) + h_target, d_target = pickle.load(f) assert h == h_target assert d == d_target diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index f818927f6..f4c8318a9 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -590,9 +590,7 @@ class TestFileJpeg: assert im2.quantization == {0: bounds_qtable} # values from wizard.txt in jpeg9-a src package. - standard_l_qtable = [ - int(s) - for s in """ + standard_l_qtable = [int(s) for s in """ 16 11 10 16 24 40 51 61 12 12 14 19 26 58 60 55 14 13 16 24 40 57 69 56 @@ -601,14 +599,9 @@ class TestFileJpeg: 24 35 55 64 81 104 113 92 49 64 78 87 103 121 120 101 72 92 95 98 112 100 103 99 - """.split( - None - ) - ] + """.split(None)] - standard_chrominance_qtable = [ - int(s) - for s in """ + standard_chrominance_qtable = [int(s) for s in """ 17 18 24 47 99 99 99 99 18 21 26 66 99 99 99 99 24 26 56 99 99 99 99 99 @@ -617,10 +610,7 @@ class TestFileJpeg: 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 - """.split( - None - ) - ] + """.split(None)] for quality in range(101): qtable_from_qtable_quality = self.roundtrip( diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index c2336c058..a71c65cac 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -738,7 +738,7 @@ class TestFileLibTiff(LibTiffTestCase): buffer_io.seek(0) with Image.open(buffer_io) as saved_im: - assert_image_similar(pilim, saved_im, 0) + assert_image_equal(pilim, saved_im) save_bytesio() save_bytesio("raw") diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 2e999eff6..90740ab57 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -119,36 +119,36 @@ def test_large_count(tmp_path: Path) -> None: _roundtrip(tmp_path, im) -def _test_buffer_overflow(tmp_path: Path, im: Image.Image, size: int = 1024) -> None: - _last = ImageFile.MAXBLOCK - ImageFile.MAXBLOCK = size - try: - _roundtrip(tmp_path, im) - finally: - ImageFile.MAXBLOCK = _last +def _test_buffer_overflow( + tmp_path: Path, im: Image.Image, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(ImageFile, "MAXBLOCK", 1024) + _roundtrip(tmp_path, im) -def test_break_in_count_overflow(tmp_path: Path) -> None: +def test_break_in_count_overflow( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: im = Image.new("L", (256, 5)) px = im.load() assert px is not None for y in range(4): for x in range(256): px[x, y] = x % 128 - _test_buffer_overflow(tmp_path, im) + _test_buffer_overflow(tmp_path, im, monkeypatch) -def test_break_one_in_loop(tmp_path: Path) -> None: +def test_break_one_in_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: im = Image.new("L", (256, 5)) px = im.load() assert px is not None for y in range(5): for x in range(256): px[x, y] = x % 128 - _test_buffer_overflow(tmp_path, im) + _test_buffer_overflow(tmp_path, im, monkeypatch) -def test_break_many_in_loop(tmp_path: Path) -> None: +def test_break_many_in_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: im = Image.new("L", (256, 5)) px = im.load() assert px is not None @@ -157,10 +157,10 @@ def test_break_many_in_loop(tmp_path: Path) -> None: px[x, y] = x % 128 for x in range(8): px[x, 4] = 16 - _test_buffer_overflow(tmp_path, im) + _test_buffer_overflow(tmp_path, im, monkeypatch) -def test_break_one_at_end(tmp_path: Path) -> None: +def test_break_one_at_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: im = Image.new("L", (256, 5)) px = im.load() assert px is not None @@ -168,10 +168,10 @@ def test_break_one_at_end(tmp_path: Path) -> None: for x in range(256): px[x, y] = x % 128 px[0, 3] = 128 + 64 - _test_buffer_overflow(tmp_path, im) + _test_buffer_overflow(tmp_path, im, monkeypatch) -def test_break_many_at_end(tmp_path: Path) -> None: +def test_break_many_at_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: im = Image.new("L", (256, 5)) px = im.load() assert px is not None @@ -181,10 +181,10 @@ def test_break_many_at_end(tmp_path: Path) -> None: for x in range(4): px[x * 2, 3] = 128 + 64 px[x + 256 - 4, 3] = 0 - _test_buffer_overflow(tmp_path, im) + _test_buffer_overflow(tmp_path, im, monkeypatch) -def test_break_padding(tmp_path: Path) -> None: +def test_break_padding(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: im = Image.new("L", (257, 5)) px = im.load() assert px is not None @@ -193,4 +193,4 @@ def test_break_padding(tmp_path: Path) -> None: px[x, y] = x % 128 for x in range(5): px[x, 3] = 0 - _test_buffer_overflow(tmp_path, im) + _test_buffer_overflow(tmp_path, im, monkeypatch) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index ed3a91285..2e0af5041 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -654,21 +654,17 @@ class TestFilePng: with pytest.raises(SyntaxError, match="Unknown compression method"): PngImagePlugin.PngImageFile("Tests/images/unknown_compression_method.png") - def test_padded_idat(self) -> None: + def test_padded_idat(self, monkeypatch: pytest.MonkeyPatch) -> None: # This image has been manually hexedited # so that the IDAT chunk has padding at the end # Set MAXBLOCK to the length of the actual data # so that the decoder finishes reading before the chunk ends - MAXBLOCK = ImageFile.MAXBLOCK - ImageFile.MAXBLOCK = 45 - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(ImageFile, "MAXBLOCK", 45) + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) with Image.open("Tests/images/padded_idat.png") as im: im.load() - ImageFile.MAXBLOCK = MAXBLOCK - ImageFile.LOAD_TRUNCATED_IMAGES = False - assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") @pytest.mark.parametrize( diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 38a88cd17..8a2636dfe 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -100,7 +100,7 @@ def test_seek_tell() -> None: im.seek(2) layer_number = im.tell() - assert layer_number == 2 + assert layer_number == 2 def test_seek_eoferror() -> None: @@ -138,7 +138,7 @@ def test_icc_profile() -> None: assert "icc_profile" in im.info icc_profile = im.info["icc_profile"] - assert len(icc_profile) == 3144 + assert len(icc_profile) == 3144 def test_no_icc_profile() -> None: @@ -158,17 +158,16 @@ def test_combined_larger_than_size() -> None: @pytest.mark.parametrize( - "test_file,raises", + "test_file", [ - ("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError), - ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), + "Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", + "Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", ], ) -def test_crashes(test_file: str, raises: type[Exception]) -> None: - with open(test_file, "rb") as f: - with pytest.raises(raises): - with Image.open(f): - pass +def test_crashes(test_file: str) -> None: + with pytest.raises(OSError): + with Image.open(test_file): + pass @pytest.mark.parametrize( @@ -179,8 +178,24 @@ def test_crashes(test_file: str, raises: type[Exception]) -> None: ], ) def test_layer_crashes(test_file: str) -> None: - with open(test_file, "rb") as f: - with Image.open(f) as im: - assert isinstance(im, PsdImagePlugin.PsdImageFile) - with pytest.raises(SyntaxError): - im.layers + with Image.open(test_file) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) + with pytest.raises(SyntaxError): + im.layers + + +@pytest.mark.parametrize( + "test_file", + [ + "Tests/images/psd-oob-write.psd", + "Tests/images/psd-oob-write-x.psd", + "Tests/images/psd-oob-write-y.psd", + ], +) +def test_bounds_crash(test_file: str) -> None: + with Image.open(test_file) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) + im.seek(im.n_frames) + + with pytest.raises(ValueError): + im.load() diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 3b3c3b4a5..03494523b 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -14,6 +14,10 @@ from .helper import assert_image_equal, hopper, is_pypy TEST_FILE = "Tests/images/hopper.spider" +def teardown_module() -> None: + del Image.EXTENSION[".spider"] + + def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 906080d15..56901f46b 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -18,7 +18,7 @@ def test_load_raw() -> None: # Currently, support for WMF/EMF is Windows-only im.load() # Compare to reference rendering - assert_image_similar_tofile(im, "Tests/images/drawing_emf_ref.png", 0) + assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref.png") # Test basic WMF open and rendering with Image.open("Tests/images/drawing.wmf") as im: diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index ab8a7f9ec..a5da76faa 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from PIL import Image, ImageDraw, ImageFont, _util from .helper import PillowLeakTestCase, features, skip_unless_feature @@ -7,11 +9,7 @@ from .helper import PillowLeakTestCase, features, skip_unless_feature original_core = ImageFont.core -class TestTTypeFontLeak(PillowLeakTestCase): - # fails at iteration 3 in main - iterations = 10 - mem_limit = 4096 # k - +class TestFontLeak(PillowLeakTestCase): def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None: im = Image.new("RGB", (255, 255), "white") draw = ImageDraw.ImageDraw(im) @@ -21,23 +19,29 @@ class TestTTypeFontLeak(PillowLeakTestCase): ) ) + +class TestTTypeFontLeak(TestFontLeak): + # fails at iteration 3 in main + iterations = 10 + mem_limit = 4096 # k + @skip_unless_feature("freetype2") def test_leak(self) -> None: ttype = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 20) self._test_font(ttype) -class TestDefaultFontLeak(TestTTypeFontLeak): +class TestDefaultFontLeak(TestFontLeak): # fails at iteration 37 in main iterations = 100 mem_limit = 1024 # k - def test_leak(self) -> None: + def test_leak(self, monkeypatch: pytest.MonkeyPatch) -> None: if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) - try: - default_font = ImageFont.load_default() - finally: - ImageFont.core = original_core - + monkeypatch.setattr( + ImageFont, + "core", + _util.DeferredError(ImportError("Disabled for testing")), + ) + default_font = ImageFont.load_default() self._test_font(default_font) diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 567ddaf13..569c2e85b 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -10,7 +10,6 @@ from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile from .helper import ( assert_image_equal_tofile, - assert_image_similar_tofile, skip_unless_feature, ) @@ -73,14 +72,14 @@ def test_draw(request: pytest.FixtureRequest, tmp_path: Path) -> None: im = Image.new("L", (130, 30), "white") draw = ImageDraw.Draw(im) draw.text((0, 0), message, "black", font=font) - assert_image_similar_tofile(im, "Tests/images/test_draw_pbm_target.png", 0) + assert_image_equal_tofile(im, "Tests/images/test_draw_pbm_target.png") def test_textsize(request: pytest.FixtureRequest, tmp_path: Path) -> None: tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) for i in range(255): - (ox, oy, dx, dy) = font.getbbox(chr(i)) + ox, oy, dx, dy = font.getbbox(chr(i)) assert ox == 0 assert oy == 0 assert dy == 20 @@ -100,7 +99,7 @@ def _test_high_characters( im = Image.new("L", (750, 30), "white") draw = ImageDraw.Draw(im) draw.text((0, 0), message, "black", font=font) - assert_image_similar_tofile(im, "Tests/images/high_ascii_chars.png", 0) + assert_image_equal_tofile(im, "Tests/images/high_ascii_chars.png") def test_high_characters(request: pytest.FixtureRequest, tmp_path: Path) -> None: diff --git a/Tests/test_font_pcf_charsets.py b/Tests/test_font_pcf_charsets.py index 895458d9d..6ebaa35ff 100644 --- a/Tests/test_font_pcf_charsets.py +++ b/Tests/test_font_pcf_charsets.py @@ -10,7 +10,6 @@ from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile from .helper import ( assert_image_equal_tofile, - assert_image_similar_tofile, skip_unless_feature, ) @@ -85,7 +84,7 @@ def test_draw(request: pytest.FixtureRequest, tmp_path: Path, encoding: str) -> draw = ImageDraw.Draw(im) message = charsets[encoding]["message"].encode(encoding) draw.text((0, 0), message, "black", font=font) - assert_image_similar_tofile(im, charsets[encoding]["image1"], 0) + assert_image_equal_tofile(im, charsets[encoding]["image1"]) @pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) @@ -95,7 +94,7 @@ def test_textsize( tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) for i in range(255): - (ox, oy, dx, dy) = font.getbbox(bytearray([i])) + ox, oy, dx, dy = font.getbbox(bytearray([i])) assert ox == 0 assert oy == 0 assert dy == 20 diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py index 861eccc11..2aeff5c34 100644 --- a/Tests/test_format_hsv.py +++ b/Tests/test_format_hsv.py @@ -29,7 +29,7 @@ def linear_gradient() -> Image.Image: im = Image.linear_gradient(mode="L") im90 = im.rotate(90) - (px, h) = im.size + px, h = im.size r = Image.new("L", (px * 3, h)) g = r.copy() @@ -54,7 +54,7 @@ def to_xxx_colorsys( ) -> Image.Image: # convert the hard way using the library colorsys routines. - (r, g, b) = im.split() + r, g, b = im.split() conv_func = int_to_float diff --git a/Tests/test_image.py b/Tests/test_image.py index afc6e8e16..32c799195 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -456,9 +456,11 @@ class TestImage: # Assert assert len(Image.ID) == id_length - def test_registered_extensions_uninitialized(self) -> None: + def test_registered_extensions_uninitialized( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: # Arrange - Image._initialized = 0 + monkeypatch.setattr(Image, "_initialized", 0) # Act Image.registered_extensions() @@ -466,6 +468,9 @@ class TestImage: # Assert assert Image._initialized == 2 + for extension in Image.EXTENSION: + assert extension in Image._EXTENSION_PLUGIN + def test_registered_extensions(self) -> None: # Arrange # Open an image to trigger plugin registration diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 07c12594a..6470ac9fc 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -278,8 +278,7 @@ class TestEmbeddable: with open("embed_pil.c", "w", encoding="utf-8") as fh: home = sys.prefix.replace("\\", "\\\\") - fh.write( - f""" + fh.write(f""" #include "Python.h" int main(int argc, char* argv[]) @@ -300,8 +299,7 @@ int main(int argc, char* argv[]) return 0; }} - """ - ) + """) objects = compiler.compile(["embed_pil.c"]) compiler.link_executable(objects, "embed_pil") diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 3e2b9fee8..12a05ec18 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -56,7 +56,7 @@ class TestImageTransform: def test_extent(self) -> None: im = hopper("RGB") - (w, h) = im.size + w, h = im.size transformed = im.transform( im.size, Image.Transform.EXTENT, @@ -72,7 +72,7 @@ class TestImageTransform: def test_quad(self) -> None: # one simple quad transform, equivalent to scale & crop upper left quad im = hopper("RGB") - (w, h) = im.size + w, h = im.size transformed = im.transform( im.size, Image.Transform.QUAD, @@ -99,7 +99,7 @@ class TestImageTransform: ) def test_fill(self, mode: str, expected_pixel: tuple[int, ...]) -> None: im = hopper(mode) - (w, h) = im.size + w, h = im.size transformed = im.transform( im.size, Image.Transform.EXTENT, @@ -112,7 +112,7 @@ class TestImageTransform: def test_mesh(self) -> None: # this should be a checkerboard of halfsized hoppers in ul, lr im = hopper("RGBA") - (w, h) = im.size + w, h = im.size transformed = im.transform( im.size, Image.Transform.MESH, @@ -174,7 +174,7 @@ class TestImageTransform: def test_alpha_premult_transform(self) -> None: def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: - (w, h) = im.size + w, h = im.size return im.transform( sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.BILINEAR ) @@ -216,7 +216,7 @@ class TestImageTransform: @pytest.mark.parametrize("mode", ("RGBA", "LA")) def test_nearest_transform(self, mode: str) -> None: def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: - (w, h) = im.size + w, h = im.size return im.transform( sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.NEAREST ) @@ -255,7 +255,7 @@ class TestImageTransform: @pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown")) def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None: with hopper() as im: - (w, h) = im.size + w, h = im.size with pytest.raises(ValueError): im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) # type: ignore[arg-type] diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 49765cd68..3bcb7b901 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -68,10 +68,22 @@ def test_sanity() -> None: draw.rectangle(list(range(4))) -def test_valueerror() -> None: +def test_new_color() -> None: with Image.open("Tests/images/chi.gif") as im: draw = ImageDraw.Draw(im) + assert im.palette is not None + assert len(im.palette.colors) == 249 + + # Test drawing a new color onto the palette draw.line((0, 0), fill=(0, 0, 0)) + assert im.palette is not None + assert len(im.palette.colors) == 250 + assert im.palette.dirty + + # Test drawing another new color, now that the palette is dirty + draw.point((0, 0), fill=(1, 0, 0)) + assert len(im.palette.colors) == 251 + assert im.convert("RGB").getpixel((0, 0)) == (1, 0, 0) def test_mode_mismatch() -> None: @@ -883,6 +895,18 @@ def test_rounded_rectangle_joined_x_different_corners() -> None: ) +def test_rounded_rectangle_radius() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im, "RGB") + + # Act + draw.rounded_rectangle((25, 25, 75, 75), 24, fill="red", outline="green", width=5) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_rounded_rectangle_radius.png") + + @pytest.mark.parametrize( "xy, radius, type", [ @@ -1461,21 +1485,15 @@ def test_stroke_multiline() -> None: @skip_unless_feature("freetype2") -def test_setting_default_font() -> None: - # Arrange +def test_setting_default_font(monkeypatch: pytest.MonkeyPatch) -> None: im = Image.new("RGB", (100, 250)) + draw = ImageDraw.Draw(im) + assert isinstance(draw.getfont(), ImageFont.load_default().__class__) + draw = ImageDraw.Draw(im) font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120) - - # Act - ImageDraw.ImageDraw.font = font - - # Assert - try: - assert draw.getfont() == font - finally: - ImageDraw.ImageDraw.font = None - assert isinstance(draw.getfont(), ImageFont.load_default().__class__) + monkeypatch.setattr(ImageDraw.ImageDraw, "font", font) + assert draw.getfont() == font def test_default_font_size() -> None: diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 7dfb3abf9..6656ee506 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -31,7 +31,7 @@ SAFEBLOCK = ImageFile.SAFEBLOCK class TestImageFile: - def test_parser(self) -> None: + def test_parser(self, monkeypatch: pytest.MonkeyPatch) -> None: def roundtrip(format: str) -> tuple[Image.Image, Image.Image]: im = hopper("L").resize((1000, 1000), Image.Resampling.NEAREST) if format in ("MSP", "XBM"): @@ -55,12 +55,9 @@ class TestImageFile: assert_image_equal(*roundtrip("IM")) assert_image_equal(*roundtrip("MSP")) if features.check("zlib"): - try: - # force multiple blocks in PNG driver - ImageFile.MAXBLOCK = 8192 - assert_image_equal(*roundtrip("PNG")) - finally: - ImageFile.MAXBLOCK = MAXBLOCK + # force multiple blocks in PNG driver + monkeypatch.setattr(ImageFile, "MAXBLOCK", 8192) + assert_image_equal(*roundtrip("PNG")) assert_image_equal(*roundtrip("PPM")) assert_image_equal(*roundtrip("TIFF")) assert_image_equal(*roundtrip("XBM")) @@ -120,14 +117,11 @@ class TestImageFile: assert (128, 128) == p.image.size @skip_unless_feature("zlib") - def test_safeblock(self) -> None: + def test_safeblock(self, monkeypatch: pytest.MonkeyPatch) -> None: im1 = hopper() - try: - ImageFile.SAFEBLOCK = 1 - im2 = fromstring(tostring(im1, "PNG")) - finally: - ImageFile.SAFEBLOCK = SAFEBLOCK + monkeypatch.setattr(ImageFile, "SAFEBLOCK", 1) + im2 = fromstring(tostring(im1, "PNG")) assert_image_equal(im1, im2) @@ -169,6 +163,13 @@ class TestImageFile: with pytest.raises(ValueError, match="Tile offset cannot be negative"): im.load() + @pytest.mark.parametrize("xy", ((-1, 0), (0, -1))) + def test_negative_tile_extents(self, xy: tuple[int, int]) -> None: + im = Image.new("1", (1, 1)) + fp = BytesIO() + with pytest.raises(SystemError, match="tile cannot extend outside image"): + ImageFile._save(im, fp, [ImageFile._Tile("raw", xy + (1, 1), 0, "1")]) + def test_no_format(self) -> None: buf = BytesIO(b"\x00" * 255) diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index 8c1cb3f58..883df051d 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -38,20 +38,18 @@ def test_invalid_mode() -> None: font._load_pilfont_data(fp, im) -def test_without_freetype() -> None: - original_core = ImageFont.core +def test_without_freetype(monkeypatch: pytest.MonkeyPatch) -> None: if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) - try: - with pytest.raises(ImportError): - ImageFont.truetype("Tests/fonts/FreeMono.ttf") + monkeypatch.setattr( + ImageFont, "core", _util.DeferredError(ImportError("Disabled for testing")) + ) + with pytest.raises(ImportError): + ImageFont.truetype("Tests/fonts/FreeMono.ttf") - assert isinstance(ImageFont.load_default(), ImageFont.ImageFont) + assert isinstance(ImageFont.load_default(), ImageFont.ImageFont) - with pytest.raises(ImportError): - ImageFont.load_default(size=14) - finally: - ImageFont.core = original_core + with pytest.raises(ImportError): + ImageFont.load_default(size=14) @pytest.mark.parametrize("font", fonts) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index daba30015..1d2fae1a6 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -22,8 +22,7 @@ def string_to_img(image_string: str) -> Image.Image: return im -A = string_to_img( - """ +A = string_to_img(""" ....... ....... ..111.. @@ -31,8 +30,7 @@ A = string_to_img( ..111.. ....... ....... - """ -) + """) def img_to_string(im: Image.Image) -> str: @@ -231,15 +229,15 @@ def test_negate() -> None: def test_incorrect_mode() -> None: - im = hopper() mop = ImageMorph.MorphOp(op_name="erosion8") - with pytest.raises(ValueError, match="Image mode must be 1 or L"): - mop.apply(im) - with pytest.raises(ValueError, match="Image mode must be 1 or L"): - mop.match(im) - with pytest.raises(ValueError, match="Image mode must be 1 or L"): - mop.get_on_pixels(im) + with hopper() as im: + with pytest.raises(ValueError, match="Image mode must be 1 or L"): + mop.apply(im) + with pytest.raises(ValueError, match="Image mode must be 1 or L"): + mop.match(im) + with pytest.raises(ValueError, match="Image mode must be 1 or L"): + mop.get_on_pixels(im) def test_add_patterns() -> None: diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 6ad21502f..10b89a2c0 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -1,10 +1,11 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path import pytest -from PIL import Image, ImagePalette +from PIL import Image, ImagePalette, PaletteFile from .helper import assert_image_equal, assert_image_equal_tofile @@ -202,6 +203,19 @@ def test_2bit_palette(tmp_path: Path) -> None: assert_image_equal_tofile(img, outfile) +def test_getpalette() -> None: + b = BytesIO(b"0 1\n1 2 3 4") + p = PaletteFile.PaletteFile(b) + + palette, rawmode = p.getpalette() + assert palette[:6] == b"\x01\x01\x01\x02\x03\x04" + assert rawmode == "RGB" + + def test_invalid_palette() -> None: with pytest.raises(OSError): ImagePalette.load("Tests/images/hopper.jpg") + + b = BytesIO(b"1" * 101) + with pytest.raises(SyntaxError, match="bad palette file"): + PaletteFile.PaletteFile(b) diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index e8468e59f..b74210513 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -87,7 +87,7 @@ if is_win32(): def test_pointer(tmp_path: Path) -> None: im = hopper() - (width, height) = im.size + width, height = im.size opath = tmp_path / "temp.png" imdib = ImageWin.Dib(im) diff --git a/Tests/test_nanoarrow.py b/Tests/test_nanoarrow.py index 69980e719..047be16c5 100644 --- a/Tests/test_nanoarrow.py +++ b/Tests/test_nanoarrow.py @@ -208,7 +208,7 @@ INT32 = DataShape( ), ) def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None: - (dtype, elt, elts_per_pixel) = data_tp + dtype, elt, elts_per_pixel = data_tp ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1] if dtype == fl_uint8_4_type: @@ -241,7 +241,7 @@ def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> Non ) @pytest.mark.parametrize("data_tp", (UINT32, INT32)) def test_from_int32array(mode: str, mask: list[int] | None, data_tp: DataShape) -> None: - (dtype, elt, elts_per_pixel) = data_tp + dtype, elt, elts_per_pixel = data_tp ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1] arr = nanoarrow.Array( diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 78f5632c5..e5c6f7d85 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -5,6 +5,8 @@ import sys from io import BytesIO from pathlib import Path +import pytest + from PIL import Image, PSDraw @@ -47,21 +49,16 @@ def test_draw_postscript(tmp_path: Path) -> None: assert os.path.getsize(tempfile) > 0 -def test_stdout() -> None: +def test_stdout(monkeypatch: pytest.MonkeyPatch) -> None: # Temporarily redirect stdout - old_stdout = sys.stdout - class MyStdOut: buffer = BytesIO() mystdout = MyStdOut() - sys.stdout = mystdout + monkeypatch.setattr(sys, "stdout", mystdout) ps = PSDraw.PSDraw() _create_document(ps) - # Reset stdout - sys.stdout = old_stdout - assert mystdout.buffer.getvalue() != b"" diff --git a/Tests/test_pyarrow.py b/Tests/test_pyarrow.py index a69504e78..7a161f2ac 100644 --- a/Tests/test_pyarrow.py +++ b/Tests/test_pyarrow.py @@ -211,7 +211,7 @@ INT32 = DataShape( ), ) def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None: - (dtype, elt, elts_per_pixel) = data_tp + dtype, elt, elts_per_pixel = data_tp ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1] arr = pyarrow.array([elt] * (ct_pixels * elts_per_pixel), type=dtype) @@ -238,7 +238,7 @@ def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> Non ), ) def test_from_int32array(mode: str, data_tp: DataShape, mask: list[int] | None) -> None: - (dtype, elt, elts_per_pixel) = data_tp + dtype, elt, elts_per_pixel = data_tp ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1] arr = pyarrow.array([elt] * (ct_pixels * elts_per_pixel), type=dtype) diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py index 5871a7213..915dbe7b6 100644 --- a/Tests/test_pyroma.py +++ b/Tests/test_pyroma.py @@ -6,10 +6,15 @@ import pytest from PIL import __version__ +TYPE_CHECKING = False + +if TYPE_CHECKING: + from importlib.metadata import PackageMetadata + pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed") -def map_metadata_keys(md): +def map_metadata_keys(md: PackageMetadata) -> dict[str, str | list[str] | None]: # Convert installed wheel metadata into canonical Core Metadata 2.4 format. # This was a utility method in pyroma 4.3.3; it was removed in 5.0. # This implementation is constructed from the relevant logic from @@ -17,16 +22,16 @@ def map_metadata_keys(md): # upstream to Pyroma as https://github.com/regebro/pyroma/pull/116, # so it may be possible to simplify this test in future. data = {} - for key in set(md.keys()): + for key in set(md): value = md.get_all(key) key = pyroma.projectdata.normalize(key) - if len(value) == 1: - value = value[0] - if value.strip() == "UNKNOWN": - continue - - data[key] = value + if value is not None and len(value) == 1: + first_value = value[0] + if first_value.strip() != "UNKNOWN": + data[key] = first_value + else: + data[key] = value return data diff --git a/Tests/test_uploader.py b/Tests/test_uploader.py index d55ceb4be..0491a22a1 100644 --- a/Tests/test_uploader.py +++ b/Tests/test_uploader.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .helper import assert_image_equal, assert_image_similar, hopper +from .helper import assert_image_equal, hopper def check_upload_equal() -> None: @@ -12,4 +12,4 @@ def check_upload_equal() -> None: def check_upload_similar() -> None: result = hopper("P").convert("RGB") target = hopper("RGB") - assert_image_similar(result, target, 0) + assert_image_equal(result, target) diff --git a/depends/install_libavif.sh b/depends/install_libavif.sh index 50ba01755..a6686f3ef 100755 --- a/depends/install_libavif.sh +++ b/depends/install_libavif.sh @@ -7,6 +7,10 @@ version=1.3.0 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 diff --git a/depends/libavif-svt4.patch b/depends/libavif-svt4.patch new file mode 100644 index 000000000..7abfc5299 --- /dev/null +++ b/depends/libavif-svt4.patch @@ -0,0 +1,14 @@ +--- a/src/codec_svt.c ++++ b/src/codec_svt.c +@@ -162,7 +162,11 @@ static avifResult svtCodecEncodeImage(avifEncoder * encoder, + #else + svt_config->logical_processors = encoder->maxThreads; + #endif ++#if SVT_AV1_CHECK_VERSION(4, 0, 0) ++ svt_config->aq_mode = 2; ++#else + svt_config->enable_adaptive_quantization = 2; ++#endif + // disable 2-pass + #if SVT_AV1_CHECK_VERSION(0, 9, 0) + svt_config->rc_stats_buffer = (SvtAv1FixedBuf) { NULL, 0 }; diff --git a/docs/dater.py b/docs/dater.py index c0302b55c..87dacbd5a 100644 --- a/docs/dater.py +++ b/docs/dater.py @@ -11,7 +11,7 @@ import subprocess TYPE_CHECKING = False if TYPE_CHECKING: - from sphinx.application import Sphinx + from typing import Any DOC_NAME_REGEX = re.compile(r"releasenotes/\d+\.\d+\.\d+") VERSION_TITLE_REGEX = re.compile(r"^(\d+\.\d+\.\d+)\n-+\n") @@ -28,7 +28,7 @@ def get_date_for(git_version: str) -> str | None: return out.split()[0] -def add_date(app: Sphinx, doc_name: str, source: list[str]) -> None: +def add_date(app: Any, doc_name: str, source: list[str]) -> None: if DOC_NAME_REGEX.match(doc_name) and (m := VERSION_TITLE_REGEX.match(source[0])): old_title = m.group(1) @@ -43,6 +43,6 @@ def add_date(app: Sphinx, doc_name: str, source: list[str]) -> None: source[0] = result -def setup(app: Sphinx) -> dict[str, bool]: +def setup(app: Any) -> dict[str, bool]: app.connect("source-read", add_date) return {"parallel_read_safe": True} 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/building-from-source.rst b/docs/installation/building-from-source.rst index c86ebe896..1655b8f60 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.17**. + above uses liblcms2. Tested with **1.19** and **2.7-2.18**. * **libwebp** provides the WebP format. diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index ee70d8401..7a8707b9a 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -57,7 +57,7 @@ These platforms are built and tested for every change. | Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | | | PyPy3 | | | +----------------------------+---------------------+ -| | 3.12 (MinGW) | x86-64 | +| | 3.13 (MinGW) | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -75,7 +75,7 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+=============================+==================+==============+ -| macOS 26 Tahoe | 3.10, 3.11, 3.12, 3.13, 3.14| 12.0.0 |arm | +| macOS 26 Tahoe | 3.10, 3.11, 3.12, 3.13, 3.14| 12.1.1 |arm | | +-----------------------------+------------------+ | | | 3.9 | 11.3.0 | | +----------------------------------+-----------------------------+------------------+--------------+ diff --git a/docs/releasenotes/12.1.1.rst b/docs/releasenotes/12.1.1.rst new file mode 100644 index 000000000..86083b4ad --- /dev/null +++ b/docs/releasenotes/12.1.1.rst @@ -0,0 +1,24 @@ +12.1.1 +------ + +Security +======== + +:cve:`2026-25990`: Fix OOB write with invalid tile extents +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Check that tile extents do not use negative x or y offsets when decoding or encoding, +and raise an error if they do, rather than allowing an OOB write. + +An out-of-bounds write may be triggered when opening a specially crafted PSD image. +This only affects Pillow >= 10.3.0. Reported by +`Yarden Porat `__. + +Other changes +============= + +Patch libavif for svt-av1 4.0 compatibility +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A patch has been added to ``depends/install_libavif.sh``, to allow libavif 1.3.0 to be +compatible with the recently released svt-av1 4.0.0. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 0f6845015..076872979 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -16,6 +16,7 @@ expected to be backported to earlier versions. versioning 12.2.0 + 12.1.1 12.1.0 12.0.0 11.3.0 diff --git a/pyproject.toml b/pyproject.toml index f4514925d..91f4750e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,14 +112,6 @@ test-requires = [ ] xbuild-tools = [ ] -[tool.cibuildwheel.macos] -# 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] -# 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.ios] # 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) @@ -139,6 +131,14 @@ test-command = [ # There's no numpy wheel for iOS (yet...) test-requires = [ ] +[tool.cibuildwheel.macos] +# 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] +# 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" @@ -217,6 +217,7 @@ testpaths = [ python_version = "3.10" pretty = true disallow_any_generics = true +disallow_untyped_defs = true enable_error_code = "ignore-without-code" extra_checks = true follow_imports = "silent" diff --git a/selftest.py b/selftest.py index c484d4e2d..64898fc92 100755 --- a/selftest.py +++ b/selftest.py @@ -76,7 +76,7 @@ def testimage() -> None: ('R', 'G', 'B') >>> im.getbbox() (0, 0, 128, 128) - >>> len(im.getdata()) + >>> len(im.get_flattened_data()) 16384 >>> im.getextrema() ((0, 255), (0, 255), (0, 255)) diff --git a/setup.py b/setup.py index 032c1c6d2..3d975950b 100644 --- a/setup.py +++ b/setup.py @@ -363,7 +363,6 @@ class pil_build_ext(build_ext): ("disable-platform-guessing", None, "Disable platform guessing"), ("debug", None, "Debug logging"), ] - + [("add-imaging-libs=", None, "Add libs to _imaging build")] ) @staticmethod @@ -374,7 +373,6 @@ class pil_build_ext(build_ext): self.disable_platform_guessing = self.check_configuration( "platform-guessing", "disable" ) - self.add_imaging_libs = "" build_ext.initialize_options(self) for x in self.feature: setattr(self, f"disable_{x}", self.check_configuration(x, "disable")) @@ -901,7 +899,6 @@ class pil_build_ext(build_ext): # core library libs: list[str | bool | None] = [] - libs.extend(self.add_imaging_libs.split()) defs: list[tuple[str, str | None]] = [] if feature.get("tiff"): libs.append(feature.get("tiff")) @@ -1092,7 +1089,11 @@ ext_modules = [ Extension("PIL._webp", ["src/_webp.c"]), Extension("PIL._avif", ["src/_avif.c"]), Extension("PIL._imagingtk", ["src/_imagingtk.c", "src/Tk/tkImaging.c"]), - Extension("PIL._imagingmath", ["src/_imagingmath.c"]), + Extension( + "PIL._imagingmath", + ["src/_imagingmath.c"], + libraries=None if sys.platform == "win32" else ["m"], + ), Extension("PIL._imagingmorph", ["src/_imagingmorph.c"]), ] diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index f175e2f4f..1c8c28ff0 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -20,6 +20,7 @@ """ Parse X Bitmap Distribution Format (BDF) """ + from __future__ import annotations from typing import BinaryIO diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 2effb816c..aeb7b0c93 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -190,7 +190,7 @@ class EpsImageFile(ImageFile.ImageFile): def _open(self) -> None: assert self.fp is not None - (length, offset) = self._find_offset(self.fp) + length, offset = self._find_offset(self.fp) # go to offset - start of "%!PS" self.fp.seek(offset) diff --git a/src/PIL/ExifTags.py b/src/PIL/ExifTags.py index 2280d5ce8..c1c05cdba 100644 --- a/src/PIL/ExifTags.py +++ b/src/PIL/ExifTags.py @@ -13,6 +13,7 @@ This module provides constants and clear-text names for various well-known EXIF tags. """ + from __future__ import annotations from enum import IntEnum diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index 297971234..0b06aac96 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -141,7 +141,7 @@ class FpxImageFile(ImageFile.ImageFile): size = i32(s, 4), i32(s, 8) # tilecount = i32(s, 12) - tilesize = i32(s, 16), i32(s, 20) + xtile, ytile = i32(s, 16), i32(s, 20) # channels = i32(s, 24) offset = i32(s, 28) length = i32(s, 32) @@ -156,7 +156,6 @@ class FpxImageFile(ImageFile.ImageFile): x = y = 0 xsize, ysize = size - xtile, ytile = tilesize self.tile = [] for i in range(0, len(s), length): @@ -224,7 +223,7 @@ class FpxImageFile(ImageFile.ImageFile): msg = "unknown/invalid compression" raise OSError(msg) - x = x + xtile + x += xtile if x >= xsize: x, y = 0, y + ytile if y >= ysize: diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index 891225ce2..d73bc1982 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -25,6 +25,7 @@ implementation is provided for convenience and demonstrational purposes only. """ + from __future__ import annotations from typing import IO diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 5f2691882..fb9587218 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -18,6 +18,7 @@ Stuff to translate curve segments to palette values (derived from the corresponding code in GIMP, written by Federico Mena Quintero. See the GIMP distribution for more information.) """ + from __future__ import annotations from math import log, pi, sin, sqrt diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 058861d67..023835fb7 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -42,7 +42,7 @@ def read_32t( fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] ) -> dict[str, Image.Image]: # The 128x128 icon seems to have an extra header for some reason. - (start, length) = start_length + start, length = start_length fobj.seek(start) sig = fobj.read(4) if sig != b"\x00\x00\x00\x00": @@ -58,7 +58,7 @@ def read_32( Read a 32bit RGB icon resource. Seems to be either uncompressed or an RLE packbits-like scheme. """ - (start, length) = start_length + start, length = start_length fobj.seek(start) pixel_size = (size[0] * size[2], size[1] * size[2]) sizesq = pixel_size[0] * pixel_size[1] @@ -111,7 +111,7 @@ def read_mk( def read_png_or_jpeg2000( fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] ) -> dict[str, Image.Image]: - (start, length) = start_length + start, length = start_length fobj.seek(start) sig = fobj.read(12) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 57ebea689..cc431a86a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -323,10 +323,112 @@ def getmodebands(mode: str) -> int: _initialized = 0 +# Mapping from file extension to plugin module name for lazy importing +_EXTENSION_PLUGIN: dict[str, str] = { + # Common formats (preinit) + ".bmp": "BmpImagePlugin", + ".dib": "BmpImagePlugin", + ".gif": "GifImagePlugin", + ".jfif": "JpegImagePlugin", + ".jpe": "JpegImagePlugin", + ".jpg": "JpegImagePlugin", + ".jpeg": "JpegImagePlugin", + ".pbm": "PpmImagePlugin", + ".pgm": "PpmImagePlugin", + ".pnm": "PpmImagePlugin", + ".ppm": "PpmImagePlugin", + ".pfm": "PpmImagePlugin", + ".png": "PngImagePlugin", + ".apng": "PngImagePlugin", + # Less common formats (init) + ".avif": "AvifImagePlugin", + ".avifs": "AvifImagePlugin", + ".blp": "BlpImagePlugin", + ".bufr": "BufrStubImagePlugin", + ".cur": "CurImagePlugin", + ".dcx": "DcxImagePlugin", + ".dds": "DdsImagePlugin", + ".ps": "EpsImagePlugin", + ".eps": "EpsImagePlugin", + ".fit": "FitsImagePlugin", + ".fits": "FitsImagePlugin", + ".fli": "FliImagePlugin", + ".flc": "FliImagePlugin", + ".fpx": "FpxImagePlugin", + ".ftc": "FtexImagePlugin", + ".ftu": "FtexImagePlugin", + ".gbr": "GbrImagePlugin", + ".grib": "GribStubImagePlugin", + ".h5": "Hdf5StubImagePlugin", + ".hdf": "Hdf5StubImagePlugin", + ".icns": "IcnsImagePlugin", + ".ico": "IcoImagePlugin", + ".im": "ImImagePlugin", + ".iim": "IptcImagePlugin", + ".jp2": "Jpeg2KImagePlugin", + ".j2k": "Jpeg2KImagePlugin", + ".jpc": "Jpeg2KImagePlugin", + ".jpf": "Jpeg2KImagePlugin", + ".jpx": "Jpeg2KImagePlugin", + ".j2c": "Jpeg2KImagePlugin", + ".mic": "MicImagePlugin", + ".mpg": "MpegImagePlugin", + ".mpeg": "MpegImagePlugin", + ".mpo": "MpoImagePlugin", + ".msp": "MspImagePlugin", + ".palm": "PalmImagePlugin", + ".pcd": "PcdImagePlugin", + ".pcx": "PcxImagePlugin", + ".pdf": "PdfImagePlugin", + ".pxr": "PixarImagePlugin", + ".psd": "PsdImagePlugin", + ".qoi": "QoiImagePlugin", + ".bw": "SgiImagePlugin", + ".rgb": "SgiImagePlugin", + ".rgba": "SgiImagePlugin", + ".sgi": "SgiImagePlugin", + ".ras": "SunImagePlugin", + ".tga": "TgaImagePlugin", + ".icb": "TgaImagePlugin", + ".vda": "TgaImagePlugin", + ".vst": "TgaImagePlugin", + ".tif": "TiffImagePlugin", + ".tiff": "TiffImagePlugin", + ".webp": "WebPImagePlugin", + ".wmf": "WmfImagePlugin", + ".emf": "WmfImagePlugin", + ".xbm": "XbmImagePlugin", + ".xpm": "XpmImagePlugin", +} + + +def _import_plugin_for_extension(ext: str | bytes) -> bool: + """Import only the plugin needed for a specific file extension.""" + if not ext: + return False + + if isinstance(ext, bytes): + ext = ext.decode() + ext = ext.lower() + if ext in EXTENSION: + return True + + plugin = _EXTENSION_PLUGIN.get(ext) + if plugin is None: + return False + + try: + logger.debug("Importing %s", plugin) + __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) + return True + except ImportError as e: + logger.debug("Image: failed to import %s: %s", plugin, e) + return False + def preinit() -> None: """ - Explicitly loads BMP, GIF, JPEG, PPM and PPM file format drivers. + Explicitly loads BMP, GIF, JPEG, PPM and PNG file format drivers. It is called when opening or saving images. """ @@ -382,11 +484,10 @@ def init() -> bool: if _initialized >= 2: return False - parent_name = __name__.rpartition(".")[0] for plugin in _plugins: try: logger.debug("Importing %s", plugin) - __import__(f"{parent_name}.{plugin}", globals(), locals(), []) + __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) @@ -892,7 +993,9 @@ class Image: else: self.im.putpalettealphas(self.info["transparency"]) self.palette.mode = "RGBA" - else: + elif self.palette.mode != mode: + # If the palette rawmode is different to the mode, + # then update the Python palette data self.palette.palette = self.im.getpalette( self.palette.mode, self.palette.mode ) @@ -2443,7 +2546,7 @@ class Image: ] def transform(x: float, y: float, matrix: list[float]) -> tuple[float, float]: - (a, b, c, d, e, f) = matrix + a, b, c, d, e, f = matrix return a * x + b * y + c, d * x + e * y + f matrix[2], matrix[5] = transform( @@ -2533,12 +2636,20 @@ class Image: # only set the name for metadata purposes filename = os.fspath(fp.name) - preinit() + if format: + preinit() + else: + filename_ext = os.path.splitext(filename)[1].lower() + ext = ( + filename_ext.decode() + if isinstance(filename_ext, bytes) + else filename_ext + ) - filename_ext = os.path.splitext(filename)[1].lower() - ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext + # Try importing only the plugin for this extension first + if not _import_plugin_for_extension(ext): + preinit() - if not format: if ext not in EXTENSION: init() try: @@ -3378,7 +3489,7 @@ def fromarrow( msg = "arrow_c_array interface not found" raise ValueError(msg) - (schema_capsule, array_capsule) = obj.__arrow_c_array__() + schema_capsule, array_capsule = obj.__arrow_c_array__() _im = core.new_arrow(mode, size, schema_capsule, array_capsule) if _im: return Image()._new(_im) @@ -3522,7 +3633,11 @@ def open( prefix = fp.read(16) - preinit() + # Try to import just the plugin needed for this file extension + # before falling back to preinit() which imports common plugins + ext = os.path.splitext(filename)[1] if filename else "" + if not _import_plugin_for_extension(ext): + preinit() warning_messages: list[str] = [] @@ -3558,14 +3673,19 @@ def open( im = _open_core(fp, filename, prefix, formats) if im is None and formats is ID: - checked_formats = ID.copy() - if init(): - im = _open_core( - fp, - filename, - prefix, - tuple(format for format in formats if format not in checked_formats), - ) + # Try preinit (few common plugins) then init (all plugins) + for loader in (preinit, init): + checked_formats = ID.copy() + loader() + if formats != checked_formats: + im = _open_core( + fp, + filename, + prefix, + tuple(f for f in formats if f not in checked_formats), + ) + if im is not None: + break if im: im._exclusive_fp = exclusive_fp diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 07fa43b06..9b0864d1a 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -487,7 +487,7 @@ class ImageDraw: if full_x: self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) - elif x1 - r - 1 > x0 + r + 1: + elif x1 - r - 1 >= x0 + r + 1: self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: left = [x0, y0, x0 + r, y1] diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index 3d68658ed..2c9e39b2c 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -22,6 +22,7 @@ .. seealso:: :py:mod:`PIL.ImageDraw` """ + from __future__ import annotations from typing import Any, AnyStr, BinaryIO @@ -117,7 +118,7 @@ class Draw: def settransform(self, offset: tuple[float, float]) -> None: """Sets a transformation offset.""" - (xoffset, yoffset) = offset + xoffset, yoffset = offset self.transform = (1, 0, xoffset, 0, 1, yoffset) def arc( diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 3390dfa97..341435437 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -801,9 +801,9 @@ class PyCodec: self.im = im if extents: - (x0, y0, x1, y1) = extents + x0, y0, x1, y1 = extents else: - (x0, y0, x1, y1) = (0, 0, 0, 0) + x0, y0, x1, y1 = (0, 0, 0, 0) if x0 == 0 and x1 == 0: self.state.xsize, self.state.ysize = self.im.size @@ -814,7 +814,7 @@ class PyCodec: self.state.ysize = y1 - y0 if self.state.xsize <= 0 or self.state.ysize <= 0: - msg = "Size cannot be negative" + msg = "Size must be positive" raise ValueError(msg) if ( diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index d11f7bf01..ae003d139 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -940,9 +940,7 @@ def load_default_imagefont() -> ImageFont: f = ImageFont() f._load_pilfont_data( # courB08 - BytesIO( - base64.b64decode( - b""" + BytesIO(base64.b64decode(b""" UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA @@ -1034,13 +1032,8 @@ AJsAEQAGAAAAAP/6AAX//wCbAAoAoAAPAAYAAAAA//oABQABAKAACgClABEABgAA////+AAGAAAA pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA//// +QAGAAIAzgAKANUAEw== -""" - ) - ), - Image.open( - BytesIO( - base64.b64decode( - b""" +""")), + Image.open(BytesIO(base64.b64decode(b""" iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9 M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g @@ -1064,10 +1057,7 @@ evta/58PTEWzr21hufPjA8N+qlnBwAAAAAD//2JiWLci5v1+HmFXDqcnULE/MxgYGBj+f6CaJQAA AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v// Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR w7IkEbzhVQAAAABJRU5ErkJggg== -""" - ) - ) - ), +"""))), ) return f @@ -1088,9 +1078,7 @@ def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: """ if isinstance(core, ModuleType) or size is not None: return truetype( - BytesIO( - base64.b64decode( - b""" + BytesIO(base64.b64decode(b""" AAEAAAAPAIAAAwBwRkZUTYwDlUAAADFoAAAAHEdERUYAqADnAAAo8AAAACRHUE9ThhmITwAAKfgAA AduR1NVQnHxefoAACkUAAAA4k9TLzJovoHLAAABeAAAAGBjbWFw5lFQMQAAA6gAAAGqZ2FzcP//AA MAACjoAAAACGdseWYmRXoPAAAGQAAAHfhoZWFkE18ayQAAAPwAAAA2aGhlYQboArEAAAE0AAAAJGh @@ -1311,9 +1299,7 @@ ABUADgAPAAAACwAQAAAAAAAAAAAAAAAAAAUAGAACAAIAAgAAAAIAGAAXAAAAGAAAABYAFgACABYAA gAWAAAAEQADAAoAFAAMAA0ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAEgAGAAEAHgAkAC YAJwApACoALQAuAC8AMgAzADcAOAA5ADoAPAA9AEUASABOAE8AUgBTAFUAVwBZAFoAWwBcAF0AcwA AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ== -""" - ) - ), +""")), 10 if size is None else size, layout_engine=Layout.BASIC, ) diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 277087a86..fa0f52fe8 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -140,7 +140,7 @@ class MspDecoder(ImageFile.PyDecoder): runtype = row[idx] idx += 1 if runtype == 0: - (runcount, runval) = struct.unpack_from("Bc", row, idx) + runcount, runval = struct.unpack_from("Bc", row, idx) img.write(runval * runcount) idx += 2 else: diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 7fd4c5c94..e6b74a918 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -100,7 +100,8 @@ class PSDraw: Draws text at the given position. You must use :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method. """ - text_bytes = bytes(text, "UTF-8") + # The font is loaded as ISOLatin1Encoding, so use latin-1 here. + text_bytes = bytes(text, "latin-1") text_bytes = b"\\(".join(text_bytes.split(b"(")) text_bytes = b"\\)".join(text_bytes.split(b")")) self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 15f712908..232adf3d3 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -210,8 +210,8 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # # -------------------------------------------------------------------- -Image.register_save("Palm", _save) +Image.register_save("PALM", _save) -Image.register_extension("Palm", ".palm") +Image.register_extension("PALM", ".palm") -Image.register_mime("Palm", "image/palm") +Image.register_mime("PALM", "image/palm") diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 866292243..848dccda5 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -290,9 +290,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # get the filename extension and register it with Image - filename_ext = os.path.splitext(filename)[1] - ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext - Image.register_extension(SpiderImageFile.format, ext) + if filename_ext := os.path.splitext(filename)[1]: + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext + Image.register_extension(SpiderImageFile.format, ext) _save(im, fp, filename) diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index fb3e1c06a..07bbf7471 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -22,6 +22,7 @@ and has been tested with a few sample files found using google. is not registered for use with :py:func:`PIL.Image.open()`. To open a WAL file, use the :py:func:`PIL.WalImageFile.open()` function instead. """ + from __future__ import annotations from typing import IO diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 3ae86242a..a85c62a93 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -45,7 +45,6 @@ if hasattr(Image.core, "drawwmf"): class WmfHandler(ImageFile.StubHandler): def open(self, im: ImageFile.StubImageFile) -> None: - im._mode = "RGB" self.bbox = im.info["wmf_bbox"] def load(self, im: ImageFile.StubImageFile) -> Image.Image: diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py index 4594ccce3..d3236c17a 100644 --- a/src/PIL/_binary.py +++ b/src/PIL/_binary.py @@ -13,6 +13,7 @@ """Binary input/output support routines.""" + from __future__ import annotations from struct import pack, unpack_from diff --git a/src/decode.c b/src/decode.c index 051623ed4..7ec461c0e 100644 --- a/src/decode.c +++ b/src/decode.c @@ -186,7 +186,8 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { state->ysize = y1 - y0; } - if (state->xsize <= 0 || state->xsize + state->xoff > (int)im->xsize || + if (state->xoff < 0 || state->xsize <= 0 || + state->xsize + state->xoff > (int)im->xsize || state->yoff < 0 || state->ysize <= 0 || state->ysize + state->yoff > (int)im->ysize) { PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); return NULL; diff --git a/src/encode.c b/src/encode.c index 513309c8d..06e4a0893 100644 --- a/src/encode.c +++ b/src/encode.c @@ -254,7 +254,8 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { state->ysize = y1 - y0; } - if (state->xsize <= 0 || state->xsize + state->xoff > im->xsize || + if (state->xoff < 0 || state->xsize <= 0 || + state->xsize + state->xoff > im->xsize || state->yoff < 0 || state->ysize <= 0 || state->ysize + state->yoff > im->ysize) { PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); return NULL; diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 3377d952c..bd9bd06b6 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -116,17 +116,17 @@ V = { "BROTLI": "1.2.0", "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", - "HARFBUZZ": "12.3.0", + "HARFBUZZ": "12.3.2", "JPEGTURBO": "3.1.3", - "LCMS2": "2.17", + "LCMS2": "2.18", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.4.1", - "LIBPNG": "1.6.53", + "LIBPNG": "1.6.54", "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1", "XZ": "5.8.2", - "ZLIBNG": "2.3.2", + "ZLIBNG": "2.3.3", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2])