diff --git a/.ci/install.sh b/.ci/install.sh index 8c4bededc..70e4c9931 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -66,7 +66,7 @@ if [[ $(uname) != CYGWIN* ]]; then pushd depends && ./install_raqm.sh && popd # libavif - pushd depends && CMAKE_POLICY_VERSION_MINIMUM=3.5 ./install_libavif.sh && popd + pushd depends && ./install_libavif.sh && popd # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index db5f89c9a..520b6e320 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.23.2 +cibuildwheel==3.0.0 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 2e3610478..44b5badab 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,9 +1,10 @@ -mypy==1.15.0 +mypy==1.16.1 IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PySide6 ipython numpy packaging +pyarrow-stubs pytest sphinx types-atheris diff --git a/.github/ISSUE_TEMPLATE/RELEASE.md b/.github/ISSUE_TEMPLATE/RELEASE.md new file mode 100644 index 000000000..20d1ac79b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/RELEASE.md @@ -0,0 +1,46 @@ +--- +name: "Maintainers only: Release" +about: For maintainers to schedule a quarterly release +labels: Release +--- + +## Main release + +Released quarterly on January 2nd, April 1st, July 1st and October 15th. + +* [ ] Open a release ticket e.g. https://github.com/python-pillow/Pillow/issues/3154 +* [ ] Develop and prepare release in `main` branch. + * [ ] Add release notes e.g. https://github.com/python-pillow/Pillow/pull/8885 +* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in `main` branch. +* [ ] Check that all the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) jobs by manually triggering them. +* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` +* [ ] Run pre-release check via `make release-test` in a freshly cloned repo. +* [ ] Create branch and tag for release e.g.: + ```bash + git branch [[MAJOR.MINOR]].x + git tag [[MAJOR.MINOR]].0 + git push --tags + ``` +* [ ] Check the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) has passed, including the "Upload release to PyPI" job. This will have been triggered by the new tag. +* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases). +* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then: + ```bash + git push --all + ``` + +## Publicize release + +* [ ] Announce release availability via [Mastodon](https://fosstodon.org/@pillow) e.g. https://fosstodon.org/@pillow/110639450470725321 + +## Documentation + +* [ ] Make sure the [default version for Read the Docs](https://pillow.readthedocs.io/en/stable/) is up-to-date with the release changes + +## Docker images + +* [ ] Update Pillow in the Docker Images repository + ```bash + git clone https://github.com/python-pillow/docker-images + cd docker-images + ./update-pillow-tag.sh [[release tag]] + ``` diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml new file mode 100644 index 000000000..e6a5f6e77 --- /dev/null +++ b/.github/workflows/test-valgrind-memory.yml @@ -0,0 +1,60 @@ +name: Test Valgrind Memory Leaks + +# like the Docker tests, but running valgrind only on *.c/*.h changes. + +# this is very expensive. Only run on the pull request. +on: + # push: + # branches: + # - "**" + # paths: + # - ".github/workflows/test-valgrind.yml" + # - "**.c" + # - "**.h" + pull_request: + paths: + - ".github/workflows/test-valgrind.yml" + - "**.c" + - "**.h" + - "depends/docker-test-valgrind-memory.sh" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + docker: [ + ubuntu-22.04-jammy-amd64-valgrind, + ] + dockerTag: [main] + + name: ${{ matrix.docker }} + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build system information + run: python3 .github/workflows/system-info.py + + - name: Docker pull + run: | + docker pull pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} + + - name: Build and Run Valgrind + run: | + # The Pillow user in the docker container is UID 1001 + sudo chown -R 1001 $GITHUB_WORKSPACE + docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} /Pillow/depends/docker-test-valgrind-memory.sh + sudo chown -R runner $GITHUB_WORKSPACE diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index bf8ec2f2c..6d8acc44f 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -31,16 +31,15 @@ env: jobs: build: - runs-on: ${{ matrix.os }} + runs-on: windows-latest strategy: fail-fast: false matrix: - python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", ">=3.13.5", "3.14"] architecture: ["x64"] - os: ["windows-latest"] include: # Test the oldest Python on 32-bit - - { python-version: "3.9", architecture: "x86", os: "windows-2019" } + - { python-version: "3.9", architecture: "x86" } timeout-minutes: 45 @@ -84,7 +83,7 @@ jobs: python3 -m pip install --upgrade pip - name: Install CPython dependencies - if: "!contains(matrix.python-version, 'pypy') && matrix.architecture != 'x86'" + if: "!contains(matrix.python-version, 'pypy') && !contains(matrix.python-version, '3.14') && matrix.architecture != 'x86'" run: | python3 -m pip install PyQt6 @@ -98,8 +97,8 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.5.0 --no-progress - echo "C:\Program Files\gs\gs10.05.0\bin" >> $env:GITHUB_PATH + choco install ghostscript --version=10.5.1 --no-progress + echo "C:\Program Files\gs\gs10.05.1\bin" >> $env:GITHUB_PATH # Install extra test images xcopy /S /Y Tests\test-images\* Tests\images diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 006d574f3..b4b516228 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,7 @@ jobs: python-version: [ "pypy3.11", "pypy3.10", + "3.14t", "3.14", "3.13t", "3.13", @@ -55,6 +56,7 @@ jobs: - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - { python-version: "3.10", PYTHONOPTIMIZE: 2 } # Free-threaded + - { python-version: "3.14t", disable-gil: true } - { python-version: "3.13t", disable-gil: true } # M1 only available for 3.10+ - { os: "macos-13", python-version: "3.9" } diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index a4592871f..996d32bc2 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -38,9 +38,9 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.3 -HARFBUZZ_VERSION=11.1.0 -LIBPNG_VERSION=1.6.47 -JPEGTURBO_VERSION=3.1.0 +HARFBUZZ_VERSION=11.2.1 +LIBPNG_VERSION=1.6.49 +JPEGTURBO_VERSION=3.1.1 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.8.1 TIFF_VERSION=4.7.0 diff --git a/.github/workflows/wheels-test.ps1 b/.github/workflows/wheels-test.ps1 index a1edc14ef..54e7fbbfc 100644 --- a/.github/workflows/wheels-test.ps1 +++ b/.github/workflows/wheels-test.ps1 @@ -9,17 +9,21 @@ if ("$venv" -like "*\cibw-run-*\pp*-win_amd64\*") { C:\vc_redist.x64.exe /install /quiet /norestart | Out-Null } $env:path += ";$pillow\winbuild\build\bin\" -& "$venv\Scripts\activate.ps1" +if (Test-Path $venv\Scripts\pypy.exe) { + $python = "pypy.exe" +} else { + $python = "python.exe" +} & reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\python.exe" /v "GlobalFlag" /t REG_SZ /d "0x02000000" /f if ("$venv" -like "*\cibw-run-*-win_amd64\*") { - & python -m pip install numpy + & $venv\Scripts\$python -m pip install numpy } cd $pillow -& python -VV +& $venv\Scripts\$python -VV if (!$?) { exit $LASTEXITCODE } -& python selftest.py +& $venv\Scripts\$python selftest.py if (!$?) { exit $LASTEXITCODE } -& python -m pytest -vx Tests\check_wheel.py +& $venv\Scripts\$python -m pytest -vx Tests\check_wheel.py if (!$?) { exit $LASTEXITCODE } -& python -m pytest -vx Tests +& $venv\Scripts\$python -m pytest -vx Tests if (!$?) { exit $LASTEXITCODE } diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 33e1976f0..229e23ef0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -58,7 +58,7 @@ jobs: - name: "macOS 10.13 x86_64" os: macos-13 cibw_arch: x86_64 - build: "cp3{12,13}*" + build: "cp3{12,13,14}*" macosx_deployment_target: "10.13" - name: "macOS 10.15 x86_64" os: macos-13 @@ -110,7 +110,6 @@ jobs: CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} - CIBW_SKIP: pp39-* MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - uses: actions/upload-artifact@v4 @@ -188,7 +187,6 @@ jobs: CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy - CIBW_SKIP: pp39-* CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm -v {project}:C:\pillow diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 000000000..5bdc48c30 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,7 @@ +# Configuration for the zizmor static analysis tool, run via pre-commit in CI +# https://woodruffw.github.io/zizmor/configuration/ +rules: + unpinned-uses: + config: + policies: + "*": ref-pin diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 140ce33be..a1a054e00 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.11.4 + rev: v0.11.12 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v20.1.0 + rev: v20.1.5 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.32.1 + rev: 0.33.0 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/woodruffw/zizmor-pre-commit - rev: v1.5.2 + rev: v1.9.0 hooks: - id: zizmor @@ -68,7 +68,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.5.1 + rev: v2.6.0 hooks: - id: pyproject-fmt diff --git a/Makefile b/Makefile index 5a8152454..ce780acbc 100644 --- a/Makefile +++ b/Makefile @@ -97,13 +97,27 @@ test: python3 -c "import pytest" > /dev/null 2>&1 || python3 -m pip install pytest python3 -m pytest -qq +.PHONY: test-p +test-p: + python3 -c "import xdist" > /dev/null 2>&1 || python3 -m pip install pytest-xdist + python3 -m pytest -qq -n auto + + .PHONY: valgrind valgrind: python3 -c "import pytest_valgrind" > /dev/null 2>&1 || python3 -m pip install pytest-valgrind - PYTHONMALLOC=malloc valgrind --suppressions=Tests/oss-fuzz/python.supp --leak-check=no \ + PILLOW_VALGRIND_TEST=true PYTHONMALLOC=malloc valgrind --suppressions=Tests/oss-fuzz/python.supp --leak-check=no \ --log-file=/tmp/valgrind-output \ python3 -m pytest --no-memcheck -vv --valgrind --valgrind-log=/tmp/valgrind-output +.PHONY: valgrind-leak +valgrind-leak: + python3 -c "import pytest_valgrind" > /dev/null 2>&1 || python3 -m pip install pytest-valgrind + PILLOW_VALGRIND_TEST=true PYTHONMALLOC=malloc valgrind --suppressions=Tests/oss-fuzz/python.supp \ + --leak-check=full --show-leak-kinds=definite --errors-for-leak-kinds=definite \ + --log-file=/tmp/valgrind-output \ + python3 -m pytest -vv --valgrind --valgrind-log=/tmp/valgrind-output + .PHONY: readme readme: python3 -c "import markdown2" > /dev/null 2>&1 || python3 -m pip install markdown2 diff --git a/README.md b/README.md index 1cae558ad..365d356a0 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ This library provides extensive file format support, an efficient internal repre The core image library is designed for fast access to data stored in a few basic pixel formats. It should provide a solid foundation for a general image processing tool. -## More Information +## More information - [Documentation](https://pillow.readthedocs.io/) - [Installation](https://pillow.readthedocs.io/en/latest/installation/basic-installation.html) @@ -107,6 +107,6 @@ The core image library is designed for fast access to data stored in a few basic - [Changelog](https://github.com/python-pillow/Pillow/releases) - [Pre-fork](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst#pre-fork) -## Report a Vulnerability +## Report a vulnerability To report a security vulnerability, please follow the procedure described in the [Tidelift security policy](https://tidelift.com/docs/security). diff --git a/RELEASING.md b/RELEASING.md index 932beb2c2..3c6188c82 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,34 +1,15 @@ -# Release Checklist +# Release checklist See https://pillow.readthedocs.io/en/stable/releasenotes/versioning.html for information about how the version numbers line up with releases. -## Main Release +## Main release Released quarterly on January 2nd, April 1st, July 1st and October 15th. -* [ ] Open a release ticket e.g. https://github.com/python-pillow/Pillow/issues/3154 -* [ ] Develop and prepare release in `main` branch. -* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in `main` branch. -* [ ] Check that all the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) jobs by manually triggering them. -* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` -* [ ] Run pre-release check via `make release-test` in a freshly cloned repo. -* [ ] Create branch and tag for release e.g.: - ```bash - git branch 5.2.x - git tag 5.2.0 - git push --tags - ``` -* [ ] Check the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) - has passed, including the "Upload release to PyPI" job. This will have been triggered - by the new tag. -* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases). -* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), - increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then: - ```bash - git push --all - ``` -## Point Release +* [ ] Create a new issue and select the "Maintainers only: Release" template. + +## Point release Released as needed for security, installation or critical bug fixes. @@ -58,7 +39,7 @@ Released as needed for security, installation or critical bug fixes. git push ``` -## Embargoed Release +## Embargoed release Released as needed privately to individual vendors for critical security-related bug fixes. @@ -82,7 +63,7 @@ Released as needed privately to individual vendors for critical security-related git push origin 2.5.x ``` -## Publicize Release +## Publicize release * [ ] Announce release availability via [Mastodon](https://fosstodon.org/@pillow) e.g. https://fosstodon.org/@pillow/110639450470725321 @@ -90,7 +71,7 @@ Released as needed privately to individual vendors for critical security-related * [ ] Make sure the [default version for Read the Docs](https://pillow.readthedocs.io/en/stable/) is up-to-date with the release changes -## Docker Images +## Docker images * [ ] Update Pillow in the Docker Images repository ```bash diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py index 8ba40ba3f..9602410da 100644 --- a/Tests/check_wheel.py +++ b/Tests/check_wheel.py @@ -11,13 +11,14 @@ from .helper import is_pypy def test_wheel_modules() -> None: expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} - # tkinter is not available in cibuildwheel installed CPython on Windows - try: - import tkinter + if sys.platform == "win32": + # tkinter is not available in cibuildwheel installed CPython on Windows + try: + import tkinter - assert tkinter - except ImportError: - expected_modules.remove("tkinter") + assert tkinter + except ImportError: + expected_modules.remove("tkinter") assert set(features.get_supported_modules()) == expected_modules diff --git a/Tests/helper.py b/Tests/helper.py index 909fff879..ec61cd263 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -161,6 +161,12 @@ def assert_tuple_approx_equal( pytest.fail(msg + ": " + repr(actuals) + " != " + repr(targets)) +def timeout_unless_slower_valgrind(timeout: float) -> pytest.MarkDecorator: + if "PILLOW_VALGRIND_TEST" in os.environ: + return pytest.mark.pil_noop_mark() + return pytest.mark.timeout(timeout) + + def skip_unless_feature(feature: str) -> pytest.MarkDecorator: reason = f"{feature} not available" return pytest.mark.skipif(not features.check(feature), reason=reason) diff --git a/Tests/images/imagedraw_rectangle_I.tiff b/Tests/images/imagedraw_rectangle_I.tiff index 9b9eda883..f0cb534b6 100644 Binary files a/Tests/images/imagedraw_rectangle_I.tiff and b/Tests/images/imagedraw_rectangle_I.tiff differ diff --git a/Tests/images/op_index.qoi b/Tests/images/op_index.qoi new file mode 100644 index 000000000..e626aafe6 Binary files /dev/null and b/Tests/images/op_index.qoi differ diff --git a/Tests/images/p_4_planes.pcx b/Tests/images/p_4_planes.pcx new file mode 100644 index 000000000..8c5743a98 Binary files /dev/null and b/Tests/images/p_4_planes.pcx differ diff --git a/Tests/oss-fuzz/python.supp b/Tests/oss-fuzz/python.supp index 36385d672..4803497ad 100644 --- a/Tests/oss-fuzz/python.supp +++ b/Tests/oss-fuzz/python.supp @@ -14,3 +14,23 @@ fun:_TIFFReadEncodedTileAndAllocBuffer ... } + +{ + + Memcheck:Leak + match-leak-kinds: all + fun:malloc + fun:_PyMem_RawMalloc + fun:PyObject_Malloc + ... +} + +{ + + Memcheck:Leak + match-leak-kinds: all + fun:malloc + fun:_PyMem_RawRealloc + fun:PyMem_Realloc + ... +} diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index 82ff14181..88479ff0d 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -47,7 +47,6 @@ def test_unknown_version() -> None: ], ) def test_old_version(deprecated: str, plural: bool, expected: str) -> None: - expected = r"" with pytest.raises(RuntimeError, match=expected): _deprecate.deprecate(deprecated, 1, plural=plural) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index bd87947c0..b2e586637 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -233,7 +233,7 @@ class TestFileAvif: with Image.open(out_gif) as reread: reread_value = reread.convert("RGB").getpixel((1, 1)) difference = sum([abs(original_value[i] - reread_value[i]) for i in range(3)]) - assert difference <= 3 + assert difference <= 6 def test_save_single_frame(self, tmp_path: Path) -> None: temp_file = tmp_path / "temp.avif" diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 9f50df22d..5f6b263a1 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -7,9 +7,8 @@ import pytest from PIL import BlpImagePlugin, Image from .helper import ( - assert_image_equal, assert_image_equal_tofile, - assert_image_similar, + assert_image_similar_tofile, hopper, ) @@ -52,18 +51,16 @@ def test_save(tmp_path: Path) -> None: im = hopper("P") im.save(f, blp_version=version) - with Image.open(f) as reloaded: - assert_image_equal(im.convert("RGB"), reloaded) + assert_image_equal_tofile(im.convert("RGB"), f) with Image.open("Tests/images/transparent.png") as im: f = tmp_path / "temp.blp" im.convert("P").save(f, blp_version=version) - with Image.open(f) as reloaded: - assert_image_similar(im, reloaded, 8) + assert_image_similar_tofile(im, f, 8) im = hopper() - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Unsupported BLP image mode"): im.save(f) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index b484a8cfa..d94de7287 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -15,6 +15,7 @@ from .helper import ( is_win32, mark_if_feature_version, skip_unless_feature, + timeout_unless_slower_valgrind, ) HAS_GHOSTSCRIPT = EpsImagePlugin.has_ghostscript() @@ -398,7 +399,7 @@ def test_emptyline() -> None: assert image.format == "EPS" -@pytest.mark.timeout(timeout=5) +@timeout_unless_slower_valgrind(5) @pytest.mark.parametrize( "test_file", ["Tests/images/eps/timeout-d675703545fee17acab56e5fec644c19979175de.eps"], diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 81df1ab0b..0fadd01d0 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -7,7 +7,12 @@ import pytest from PIL import FliImagePlugin, Image, ImageFile -from .helper import assert_image_equal, assert_image_equal_tofile, is_pypy +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + is_pypy, + timeout_unless_slower_valgrind, +) # created as an export of a palette image from Gimp2.6 # save as...-> hopper.fli, default options. @@ -189,7 +194,7 @@ def test_seek() -> None: "Tests/images/timeout-bff0a9dc7243a8e6ede2408d2ffa6a9964698b87.fli", ], ) -@pytest.mark.timeout(timeout=3) +@timeout_unless_slower_valgrind(3) def test_timeouts(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 79f0ec1a8..614044d97 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -32,6 +32,7 @@ from .helper import ( is_win32, mark_if_feature_version, skip_unless_feature, + timeout_unless_slower_valgrind, ) ElementTree: ModuleType | None @@ -144,14 +145,16 @@ class TestFileJpeg: assert k > 0.9 # roundtrip, and check again im = self.roundtrip(im) - c, m, y, k = (x / 255.0 for x in im.getpixel((0, 0))) + cmyk = im.getpixel((0, 0)) + assert isinstance(cmyk, tuple) + c, m, y, k = (x / 255.0 for x in cmyk) assert c == 0.0 assert m > 0.8 assert y > 0.8 assert k == 0.0 - c, m, y, k = ( - x / 255.0 for x in im.getpixel((im.size[0] - 1, im.size[1] - 1)) - ) + cmyk = im.getpixel((im.size[0] - 1, im.size[1] - 1)) + assert isinstance(cmyk, tuple) + k = cmyk[3] / 255.0 assert k > 0.9 def test_rgb(self) -> None: @@ -1033,7 +1036,7 @@ class TestFileJpeg: with pytest.raises(ValueError): im.save(f, xmp=b"1" * 65505) - @pytest.mark.timeout(timeout=1) + @timeout_unless_slower_valgrind(1) def test_eof(self, monkeypatch: pytest.MonkeyPatch) -> None: # Even though this decoder never says that it is finished # the image should still end when there is no new data @@ -1064,10 +1067,16 @@ class TestFileJpeg: for marker in b"\xff\xd8", b"\xff\xd9": assert marker in data[1] assert marker in data[2] - # DHT, DQT - for marker in b"\xff\xc4", b"\xff\xdb": + + # DQT + markers = [b"\xff\xdb"] + if features.check_feature("libjpeg_turbo"): + # DHT + markers.append(b"\xff\xc4") + for marker in markers: assert marker in data[1] assert marker not in data[2] + # SOF0, SOS, APP0 (JFIF header) for marker in b"\xff\xc0", b"\xff\xda", b"\xff\xe0": assert marker not in data[1] diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 5d7fd1c1b..2e999eff6 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -37,6 +37,11 @@ def test_sanity(tmp_path: Path) -> None: im.save(f) +def test_p_4_planes() -> None: + with Image.open("Tests/images/p_4_planes.pcx") as im: + assert im.getpixel((0, 0)) == 3 + + def test_bad_image_size() -> None: with open("Tests/images/pil184.pcx", "rb") as fp: data = fp.read() diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index bde1e3ab8..a2218673b 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -13,7 +13,12 @@ import pytest from PIL import Image, PdfParser, features -from .helper import hopper, mark_if_feature_version, skip_unless_feature +from .helper import ( + hopper, + mark_if_feature_version, + skip_unless_feature, + timeout_unless_slower_valgrind, +) def helper_save_as_pdf(tmp_path: Path, mode: str, **kwargs: Any) -> str: @@ -339,8 +344,7 @@ def test_pdf_append_to_bytesio() -> None: assert len(f.getvalue()) > initial_size -@pytest.mark.timeout(1) -@pytest.mark.skipif("PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower") +@timeout_unless_slower_valgrind(1) @pytest.mark.parametrize("newline", (b"\r", b"\n")) def test_redos(newline: bytes) -> None: malicious = b" trailer<<>>" + newline * 3456 diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 0f0886ab8..15f67385a 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -100,11 +100,11 @@ class TestFilePng: assert im.format == "PNG" assert im.get_format_mimetype() == "image/png" - for mode in ["1", "L", "P", "RGB", "I", "I;16", "I;16B"]: + for mode in ["1", "L", "P", "RGB", "I;16", "I;16B"]: im = hopper(mode) im.save(test_file) with Image.open(test_file) as reloaded: - if mode in ("I", "I;16B"): + if mode == "I;16B": reloaded = reloaded.convert(mode) assert_image_equal(reloaded, im) @@ -801,6 +801,16 @@ class TestFilePng: with Image.open("Tests/images/truncated_end_chunk.png") as im: assert_image_equal_tofile(im, "Tests/images/hopper.png") + def test_deprecation(self, tmp_path: Path) -> None: + test_file = tmp_path / "out.png" + + im = hopper("I") + with pytest.warns(DeprecationWarning): + im.save(test_file) + + with Image.open(test_file) as reloaded: + assert_image_equal(im, reloaded.convert("I")) + @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") @skip_unless_feature("zlib") diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 41e2b5416..68f2f9468 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -288,14 +288,16 @@ def test_non_integer_token(tmp_path: Path) -> None: pass -def test_header_token_too_long(tmp_path: Path) -> None: +@pytest.mark.parametrize("data", (b"P3\x0cAAAAAAAAAA\xee", b"P6\n 01234567890")) +def test_header_token_too_long(tmp_path: Path, data: bytes) -> None: path = tmp_path / "temp.ppm" with open(path, "wb") as f: - f.write(b"P6\n 01234567890") + f.write(data) - with pytest.raises(ValueError, match="Token too long in file header: 01234567890"): + with pytest.raises(ValueError) as e: with Image.open(path): pass + assert "Token too long in file header: " in repr(e) def test_truncated_file(tmp_path: Path) -> None: diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index fd4b981ce..b9becb24f 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -1,10 +1,12 @@ from __future__ import annotations +from pathlib import Path + import pytest from PIL import Image, QoiImagePlugin -from .helper import assert_image_equal_tofile +from .helper import assert_image_equal_tofile, hopper def test_sanity() -> None: @@ -28,3 +30,28 @@ def test_invalid_file() -> None: with pytest.raises(SyntaxError): QoiImagePlugin.QoiImageFile(invalid_file) + + +def test_op_index() -> None: + # QOI_OP_INDEX as the first chunk + with Image.open("Tests/images/op_index.qoi") as im: + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + + +def test_save(tmp_path: Path) -> None: + f = tmp_path / "temp.qoi" + + im = hopper() + im.save(f, colorspace="sRGB") + + assert_image_equal_tofile(im, f) + + for path in ("Tests/images/default_font.png", "Tests/images/pil123rgba.png"): + with Image.open(path) as im: + im.save(f) + + assert_image_equal_tofile(im, f) + + im = hopper("P") + with pytest.raises(ValueError, match="Unsupported QOI image mode"): + im.save(f) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 502d9df9a..e92b97c8a 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -14,6 +14,7 @@ from PIL import ( ImageFile, JpegImagePlugin, TiffImagePlugin, + TiffTags, UnidentifiedImageError, ) from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION @@ -26,6 +27,7 @@ from .helper import ( hopper, is_pypy, is_win32, + timeout_unless_slower_valgrind, ) ElementTree: ModuleType | None @@ -47,25 +49,10 @@ class TestFileTiff: assert im.size == (128, 128) assert im.format == "TIFF" - hopper("1").save(filename) - with Image.open(filename): - pass - - hopper("L").save(filename) - with Image.open(filename): - pass - - hopper("P").save(filename) - with Image.open(filename): - pass - - hopper("RGB").save(filename) - with Image.open(filename): - pass - - hopper("I").save(filename) - with Image.open(filename): - pass + for mode in ("1", "L", "P", "RGB", "I", "I;16", "I;16L"): + hopper(mode).save(filename) + with Image.open(filename): + pass @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file(self) -> None: @@ -899,6 +886,29 @@ class TestFileTiff: assert description[0]["format"] == "image/tiff" assert description[3]["BitsPerSample"]["Seq"]["li"] == ["8", "8", "8"] + def test_getxmp_undefined(self, tmp_path: Path) -> None: + tmpfile = tmp_path / "temp.tif" + im = Image.new("L", (1, 1)) + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd.tagtype[700] = TiffTags.UNDEFINED + with Image.open("Tests/images/lab.tif") as im_xmp: + ifd[700] = im_xmp.info["xmp"] + im.save(tmpfile, tiffinfo=ifd) + + with Image.open(tmpfile) as im_reloaded: + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im_reloaded.getxmp() == {} + else: + assert "xmp" in im_reloaded.info + xmp = im_reloaded.getxmp() + + description = xmp["xmpmeta"]["RDF"]["Description"] + assert description[0]["format"] == "image/tiff" + def test_get_photoshop_blocks(self) -> None: with Image.open("Tests/images/lab.tif") as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) @@ -988,7 +998,7 @@ class TestFileTiff: with pytest.raises(OSError): im.load() - @pytest.mark.timeout(6) + @timeout_unless_slower_valgrind(6) @pytest.mark.filterwarnings("ignore:Truncated File Read") def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/timeout-6646305047838720") as im: @@ -1001,7 +1011,7 @@ class TestFileTiff: "Tests/images/oom-225817ca0f8c663be7ab4b9e717b02c661e66834.tif", ], ) - @pytest.mark.timeout(2) + @timeout_unless_slower_valgrind(2) def test_oom(self, test_file: str) -> None: with pytest.raises(UnidentifiedImageError): with pytest.warns(UserWarning): diff --git a/Tests/test_image.py b/Tests/test_image.py index 7e6118d52..b018b4309 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -34,6 +34,7 @@ from .helper import ( is_win32, mark_if_feature_version, skip_unless_feature, + timeout_unless_slower_valgrind, ) ElementTree: ModuleType | None @@ -572,10 +573,7 @@ class TestImage: i = Image.new("RGB", [1, 1]) assert isinstance(i.size, tuple) - @pytest.mark.timeout(0.75) - @pytest.mark.skipif( - "PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower" - ) + @timeout_unless_slower_valgrind(0.75) @pytest.mark.parametrize("size", ((0, 100000000), (100000000, 0))) def test_empty_image(self, size: tuple[int, int]) -> None: Image.new("RGB", size) @@ -673,6 +671,7 @@ class TestImage: im_remapped = im.remap_palette(list(range(256))) assert_image_equal(im, im_remapped) assert im.palette is not None + assert im_remapped.palette is not None assert im.palette.palette == im_remapped.palette.palette # Test illegal image mode @@ -975,6 +974,11 @@ class TestImage: assert tag not in exif.get_ifd(0x8769) assert exif.get_ifd(0xA005) + def test_exif_from_xmp_bytes(self) -> None: + im = Image.new("RGB", (1, 1)) + im.info["xmp"] = b'\xff tiff:Orientation="2"' + assert im.getexif()[274] == 2 + def test_empty_xmp(self) -> None: with Image.open("Tests/images/hopper.gif") as im: if ElementTree is None: @@ -991,7 +995,7 @@ class TestImage: im = Image.new("RGB", (1, 1)) im.info["xmp"] = ( b'\n' - b'\n\x00\x00' + b'\n\x00\x00 ' ) if ElementTree is None: with pytest.warns( diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index eb2309e0f..2c71dceb8 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -101,7 +101,8 @@ def test_fromarray_strides_without_tobytes() -> None: with pytest.raises(ValueError): wrapped = Wrapper({"shape": (1, 1), "strides": (1, 1)}) - Image.fromarray(wrapped, "L") + with pytest.warns(DeprecationWarning): + Image.fromarray(wrapped, "L") def test_fromarray_palette() -> None: @@ -110,7 +111,8 @@ def test_fromarray_palette() -> None: a = numpy.array(i) # Act - out = Image.fromarray(a, "P") + with pytest.warns(DeprecationWarning): + out = Image.fromarray(a, "P") # Assert that the Python and C palettes match assert out.palette is not None diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index ce6209c0d..73b25ed51 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -462,7 +462,7 @@ class TestCoreResampleBox: im.resize((32, 32), resample, (20, 20, 20, 100)) im.resize((32, 32), resample, (20, 20, 100, 20)) - with pytest.raises(TypeError, match="must be sequence of length 4"): + with pytest.raises(TypeError, match="must be (sequence|tuple) of length 4"): im.resize((32, 32), resample, (im.width, im.height)) # type: ignore[arg-type] with pytest.raises(ValueError, match="can't be negative"): diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index ffe9c0979..37669a2e5 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -783,9 +783,10 @@ def test_rectangle_I16(bbox: Coords) -> None: draw = ImageDraw.Draw(im) # Act - draw.rectangle(bbox, outline=0xFFFF) + draw.rectangle(bbox, outline=0xCDEF) # Assert + assert im.getpixel((X0, Y0)) == 0xCDEF assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_I.tiff") diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index 695aecbde..3eb98d379 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -7,7 +7,7 @@ import pytest from PIL import Image, ImageDraw, ImageFont, _util, features -from .helper import assert_image_equal_tofile +from .helper import assert_image_equal_tofile, timeout_unless_slower_valgrind fonts = [ImageFont.load_default_imagefont()] if not features.check_module("freetype2"): @@ -72,7 +72,7 @@ def test_decompression_bomb() -> None: font.getmask("A" * 1_000_000) -@pytest.mark.timeout(4) +@timeout_unless_slower_valgrind(4) def test_oom() -> None: glyph = struct.pack( ">hhhhhhhhhh", 1, 0, -32767, -32767, 32767, 32767, -32767, -32767, 32767, 32767 diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index 1c48cb743..54cef00ad 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -162,3 +162,13 @@ def test_pickle_font_file(tmp_path: Path, protocol: int) -> None: # Assert helper_assert_pickled_font_images(font, unpickled_font) + + +def test_load_earlier_data() -> None: + im = pickle.loads( + b"\x80\x04\x95@\x00\x00\x00\x00\x00\x00\x00\x8c\x12PIL.PngImagePlugin" + b"\x94\x8c\x0cPngImageFile\x94\x93\x94)\x81\x94]\x94(}\x94\x8c\x01L\x94K\x01" + b"K\x01\x86\x94NC\x01\x00\x94eb." + ) + assert im.mode == "L" + assert im.size == (1, 1) diff --git a/Tests/test_pyarrow.py b/Tests/test_pyarrow.py index ece9f8f26..8dad94fe0 100644 --- a/Tests/test_pyarrow.py +++ b/Tests/test_pyarrow.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any # undone +from typing import Any, NamedTuple import pytest @@ -10,30 +10,73 @@ from .helper import ( assert_deep_equal, assert_image_equal, hopper, + is_big_endian, ) -pyarrow = pytest.importorskip("pyarrow", reason="PyArrow not installed") +TYPE_CHECKING = False +if TYPE_CHECKING: + import pyarrow +else: + pyarrow = pytest.importorskip("pyarrow", reason="PyArrow not installed") TEST_IMAGE_SIZE = (10, 10) def _test_img_equals_pyarray( - img: Image.Image, arr: Any, mask: list[int] | None + img: Image.Image, arr: Any, mask: list[int] | None, elts_per_pixel: int = 1 ) -> None: - assert img.height * img.width == len(arr) + assert img.height * img.width * elts_per_pixel == len(arr) px = img.load() assert px is not None + if elts_per_pixel > 1 and mask is None: + # have to do element-wise comparison when we're comparing + # flattened r,g,b,a to a pixel. + mask = list(range(elts_per_pixel)) for x in range(0, img.size[0], int(img.size[0] / 10)): for y in range(0, img.size[1], int(img.size[1] / 10)): if mask: + pixel = px[x, y] + assert isinstance(pixel, tuple) for ix, elt in enumerate(mask): - pixel = px[x, y] - assert isinstance(pixel, tuple) - assert pixel[ix] == arr[y * img.width + x].as_py()[elt] + if elts_per_pixel == 1: + assert pixel[ix] == arr[y * img.width + x].as_py()[elt] + else: + assert ( + pixel[ix] + == arr[(y * img.width + x) * elts_per_pixel + elt].as_py() + ) else: assert_deep_equal(px[x, y], arr[y * img.width + x].as_py()) +def _test_img_equals_int32_pyarray( + img: Image.Image, arr: Any, mask: list[int] | None, elts_per_pixel: int = 1 +) -> None: + assert img.height * img.width * elts_per_pixel == len(arr) + px = img.load() + assert px is not None + if mask is None: + # have to do element-wise comparison when we're comparing + # flattened rgba in an uint32 to a pixel. + mask = list(range(elts_per_pixel)) + for x in range(0, img.size[0], int(img.size[0] / 10)): + for y in range(0, img.size[1], int(img.size[1] / 10)): + pixel = px[x, y] + assert isinstance(pixel, tuple) + arr_pixel_int = arr[y * img.width + x].as_py() + arr_pixel_tuple = ( + arr_pixel_int % 256, + (arr_pixel_int // 256) % 256, + (arr_pixel_int // 256**2) % 256, + (arr_pixel_int // 256**3), + ) + if is_big_endian(): + arr_pixel_tuple = arr_pixel_tuple[::-1] + + for ix, elt in enumerate(mask): + assert pixel[ix] == arr_pixel_tuple[elt] + + # really hard to get a non-nullable list type fl_uint8_4_type = pyarrow.field( "_", pyarrow.list_(pyarrow.field("_", pyarrow.uint8()).with_nullable(False), 4) @@ -55,14 +98,14 @@ fl_uint8_4_type = pyarrow.field( ("HSV", fl_uint8_4_type, [0, 1, 2]), ), ) -def test_to_array(mode: str, dtype: Any, mask: list[int] | None) -> None: +def test_to_array(mode: str, dtype: pyarrow.DataType, mask: list[int] | None) -> None: img = hopper(mode) # Resize to non-square img = img.crop((3, 0, 124, 127)) assert img.size == (121, 127) - arr = pyarrow.array(img) + arr = pyarrow.array(img) # type: ignore[call-overload] _test_img_equals_pyarray(img, arr, mask) assert arr.type == dtype @@ -79,8 +122,8 @@ def test_lifetime() -> None: img = hopper("L") - arr_1 = pyarrow.array(img) - arr_2 = pyarrow.array(img) + arr_1 = pyarrow.array(img) # type: ignore[call-overload] + arr_2 = pyarrow.array(img) # type: ignore[call-overload] del img @@ -97,8 +140,8 @@ def test_lifetime2() -> None: img = hopper("L") - arr_1 = pyarrow.array(img) - arr_2 = pyarrow.array(img) + arr_1 = pyarrow.array(img) # type: ignore[call-overload] + arr_2 = pyarrow.array(img) # type: ignore[call-overload] assert arr_1.sum().as_py() > 0 del arr_1 @@ -110,3 +153,94 @@ def test_lifetime2() -> None: px = img2.load() assert px # make mypy happy assert isinstance(px[0, 0], int) + + +class DataShape(NamedTuple): + dtype: pyarrow.DataType + # Strictly speaking, elt should be a pixel or pixel component, so + # list[uint8][4], float, int, uint32, uint8, etc. But more + # correctly, it should be exactly the dtype from the line above. + elt: Any + elts_per_pixel: int + + +UINT_ARR = DataShape( + dtype=fl_uint8_4_type, + elt=[1, 2, 3, 4], # array of 4 uint8 per pixel + elts_per_pixel=1, # only one array per pixel +) + +UINT = DataShape( + dtype=pyarrow.uint8(), + elt=3, # one uint8, + elts_per_pixel=4, # but repeated 4x per pixel +) + +UINT32 = DataShape( + dtype=pyarrow.uint32(), + elt=0xABCDEF45, # one packed int, doesn't fit in a int32 > 0x80000000 + elts_per_pixel=1, # one per pixel +) + +INT32 = DataShape( + dtype=pyarrow.uint32(), + elt=0x12CDEF45, # one packed int + elts_per_pixel=1, # one per pixel +) + + +@pytest.mark.parametrize( + "mode, data_tp, mask", + ( + ("L", DataShape(pyarrow.uint8(), 3, 1), None), + ("I", DataShape(pyarrow.int32(), 1 << 24, 1), None), + ("F", DataShape(pyarrow.float32(), 3.14159, 1), None), + ("LA", UINT_ARR, [0, 3]), + ("LA", UINT, [0, 3]), + ("RGB", UINT_ARR, [0, 1, 2]), + ("RGBA", UINT_ARR, None), + ("CMYK", UINT_ARR, None), + ("YCbCr", UINT_ARR, [0, 1, 2]), + ("HSV", UINT_ARR, [0, 1, 2]), + ("RGB", UINT, [0, 1, 2]), + ("RGBA", UINT, None), + ("CMYK", UINT, None), + ("YCbCr", UINT, [0, 1, 2]), + ("HSV", UINT, [0, 1, 2]), + ), +) +def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None: + (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) + img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE) + + _test_img_equals_pyarray(img, arr, mask, elts_per_pixel) + + +@pytest.mark.parametrize( + "mode, data_tp, mask", + ( + ("LA", UINT32, [0, 3]), + ("RGB", UINT32, [0, 1, 2]), + ("RGBA", UINT32, None), + ("CMYK", UINT32, None), + ("YCbCr", UINT32, [0, 1, 2]), + ("HSV", UINT32, [0, 1, 2]), + ("LA", INT32, [0, 3]), + ("RGB", INT32, [0, 1, 2]), + ("RGBA", INT32, None), + ("CMYK", INT32, None), + ("YCbCr", INT32, [0, 1, 2]), + ("HSV", INT32, [0, 1, 2]), + ), +) +def test_from_int32array(mode: str, data_tp: DataShape, mask: list[int] | None) -> None: + (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) + img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE) + + _test_img_equals_int32_pyarray(img, arr, mask, elts_per_pixel) diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 073e5415c..976f62384 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -52,3 +52,17 @@ def test_tiff_crashes(test_file: str) -> None: pytest.skip("test image not found") except OSError: pass + + +def test_tiff_mmap() -> None: + try: + with Image.open("Tests/images/crash_mmap.tif") as im: + im.seek(1) + im.load() + + im.seek(0) + im.load() + except FileNotFoundError: + if on_ci(): + raise + pytest.skip("test image not found") diff --git a/depends/docker-test-valgrind-memory.sh b/depends/docker-test-valgrind-memory.sh new file mode 100755 index 000000000..f0d1d851d --- /dev/null +++ b/depends/docker-test-valgrind-memory.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +## Run this as the test script in the Docker valgrind image. +## Note -- can be included directly into the Docker image, +## but requires the current python.supp. + +source /vpy3/bin/activate +cd /Pillow +make clean +make install +make valgrind-leak diff --git a/depends/install_libavif.sh b/depends/install_libavif.sh index fc10d3e54..26af8a36c 100755 --- a/depends/install_libavif.sh +++ b/depends/install_libavif.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -eo pipefail -version=1.2.1 +version=1.3.0 ./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz diff --git a/docs/Makefile b/docs/Makefile index 1e6c06ede..8c1019294 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,17 +3,23 @@ # You can set these variables from the command line. PYTHON = python3 -SPHINXOPTS = SPHINXBUILD = $(PYTHON) -m sphinx.cmd.build -PAPER = +SPHINXOPTS = --fail-on-warning BUILDDIR = _build +BUILDER = html +JOBS = auto +PAPER = # Internal variables. PAPEROPT_a4 = --define latex_paper_size=a4 PAPEROPT_letter = --define latex_paper_size=letter -ALLSPHINXOPTS = --doctree-dir $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +ALLSPHINXOPTS = --builder $(BUILDER) \ + --doctree-dir $(BUILDDIR)/doctrees \ + --jobs $(JOBS) \ + $(PAPEROPT_$(PAPER)) \ + $(SPHINXOPTS) \ + . $(BUILDDIR)/$(BUILDER) .PHONY: help help: @@ -36,31 +42,19 @@ install-sphinx: .PHONY: html html: $(MAKE) install-sphinx - $(SPHINXBUILD) --builder html --fail-on-warning --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + $(SPHINXBUILD) $(ALLSPHINXOPTS) .PHONY: dirhtml -dirhtml: - $(MAKE) install-sphinx - $(SPHINXBUILD) --builder dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." +dirhtml: BUILDER = dirhtml +dirhtml: html .PHONY: singlehtml -singlehtml: - $(MAKE) install-sphinx - $(SPHINXBUILD) --builder singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." +singlehtml: BUILDER = singlehtml +singlehtml: html .PHONY: linkcheck -linkcheck: - $(MAKE) install-sphinx - $(SPHINXBUILD) --builder linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." +linkcheck: BUILDER = linkcheck +linkcheck: html .PHONY: htmlview htmlview: html diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 0490ba439..def98b80a 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -193,6 +193,28 @@ Image.Image.get_child_images() method uses an image's file pointer, and so child images could only be retrieved from an :py:class:`PIL.ImageFile.ImageFile` instance. +Image.fromarray mode parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.3.0 + +The ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` has been deprecated. The +mode can be automatically determined from the object's shape and type instead. + +Saving I mode images as PNG +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.3.0 + +In order to fit the 32 bits of I mode images into PNG, when PNG images can only contain +at most 16 bits for a channel, Pillow has been clipping the values. Rather than quietly +changing the data, this is now deprecated. Instead, the image can be converted to +another mode before saving:: + + from PIL import Image + im = Image.new("I", (1, 1)) + im.convert("I;16").save("out.png") + Removed features ---------------- diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 5ca549c37..a15e84574 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1082,6 +1082,26 @@ Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L``, ``I Since Pillow 9.2.0, "plain" (P1 to P3) formats can be read as well. +QOI +^^^ + +.. versionadded:: 9.5.0 + +Pillow reads and writes images in Quite OK Image format using a Python codec. If you +wish to write code specifically for this format, :pypi:`qoi` is an alternative library +that uses C to decode the image and interfaces with NumPy. + +.. _qoi-saving: + +Saving +~~~~~~ + +The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: + +**colorspace** + If set to "sRGB", the colorspace will be written as sRGB with linear alpha, instead + of all channels being linear. + SGI ^^^ @@ -1578,15 +1598,6 @@ PSD Pillow identifies and reads PSD files written by Adobe Photoshop 2.5 and 3.0. -QOI -^^^ - -.. versionadded:: 9.5.0 - -Pillow reads images in Quite OK Image format using a Python decoder. If you wish to -write code specifically for this format, :pypi:`qoi` is an alternative library that -uses C to decode the image and interfaces with NumPy. - SUN ^^^ diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index c72568b20..8988a92ce 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -194,9 +194,9 @@ Many of Pillow's features require external libraries: pacman -S \ mingw-w64-x86_64-gcc \ - mingw-w64-x86_64-python3 \ - mingw-w64-x86_64-python3-pip \ - mingw-w64-x86_64-python3-setuptools + mingw-w64-x86_64-python \ + mingw-w64-x86_64-python-pip \ + mingw-w64-x86_64-python-setuptools Prerequisites are installed on **MSYS2 MinGW 64-bit** with:: diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 93486d034..a56f94316 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -40,18 +40,20 @@ These platforms are built and tested for every change. | macOS 13 Ventura | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 | -| | PyPy3 | | +| | 3.14, PyPy3 | | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 22.04 LTS (Jammy) | 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, 3.13, PyPy3 | | +| Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 24.04 LTS (Noble) | 3.12 | x86-64, arm64v8, | -| | | ppc64le, s390x | +| Ubuntu Linux 24.04 LTS (Noble) | 3.9, 3.10, 3.11, | x86-64 | +| | 3.12, 3.13, 3.14, PyPy3 | | +| +----------------------------+---------------------+ +| | 3.12 | arm64v8, ppc64le, | +| | | s390x | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2019 | 3.9 | x86 | -+----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.10, 3.11, 3.12, 3.13, | x86-64 | -| | PyPy3 | | +| Windows Server 2022 | 3.9 | x86 | +| +----------------------------+---------------------+ +| | 3.10, 3.11, 3.12, 3.13, | x86-64 | +| | 3.14, PyPy3 | | | +----------------------------+---------------------+ | | 3.12 (MinGW) | x86-64 | | +----------------------------+---------------------+ diff --git a/docs/make.bat b/docs/make.bat index 4126f786b..9d15537fb 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -7,10 +7,8 @@ if "%SPHINXBUILD%" == "" ( ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help diff --git a/docs/reference/ImageFont.rst b/docs/reference/ImageFont.rst index 8b2f92323..aac55fe6b 100644 --- a/docs/reference/ImageFont.rst +++ b/docs/reference/ImageFont.rst @@ -18,6 +18,9 @@ OpenType fonts (as well as other font formats supported by the FreeType library). For earlier versions, TrueType support is only available as part of the imToolkit package. +When measuring text sizes, this module will not break at newline characters. For +multiline text, see the :py:mod:`~PIL.ImageDraw` module. + .. warning:: To protect against potential DOS attacks when using arbitrary strings as text input, Pillow will raise a :py:exc:`ValueError` if the number of characters diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst new file mode 100644 index 000000000..82e4d44b6 --- /dev/null +++ b/docs/releasenotes/11.3.0.rst @@ -0,0 +1,89 @@ +11.3.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards incompatible changes +============================== + +TODO +^^^^ + +Deprecations +============ + +Image.fromarray mode parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` has been deprecated. The +mode can be automatically determined from the object's shape and type instead. + +Saving I mode images as PNG +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In order to fit the 32 bits of I mode images into PNG, when PNG images can only contain +at most 16 bits for a channel, Pillow has been clipping the values. Rather than quietly +changing the data, this is now deprecated. Instead, the image can be converted to +another mode before saving:: + + from PIL import Image + im = Image.new("I", (1, 1)) + im.convert("I;16").save("out.png") + +API changes +=========== + +TODO +^^^^ + +TODO + +API additions +============= + +TODO +^^^^ + +TODO + +Other changes +============= + +Added QOI saving +^^^^^^^^^^^^^^^^ + +Support has been added for saving QOI images. ``colorspace`` can be used to specify the +colorspace as sRGB with linear alpha, e.g. ``im.save("out.qoi", colorspace="sRGB")``. +By default, all channels will be linear. + +Support using more screenshot utilities with ImageGrab on Linux +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.ImageGrab.grab` is now able to use GNOME Screenshot, grim or Spectacle +on Linux in order to take a snapshot of the screen. + +Do not build against libavif < 1 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow only supports libavif 1.0.0 or later. In order to prevent errors when building +from source, if a user happens to have an earlier libavif on their system, Pillow will +now ignore it. + +Python 3.14 beta +^^^^^^^^^^^^^^^^ + +To help other projects prepare for Python 3.14, wheels are now built for the +3.14 beta as a preview. This is not official support for Python 3.14, but rather +an opportunity for you to test how Pillow works with the beta and report any +problems. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 5d7b21d59..a85f1e075 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,7 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 11.3.0 11.2.1 11.1.0 11.0.0 diff --git a/pyproject.toml b/pyproject.toml index a3ff9723b..683ab24ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ optional-dependencies.tests = [ "pytest", "pytest-cov", "pytest-timeout", + "pytest-xdist", "trove-classifiers>=2024.10.12", ] diff --git a/setup.py b/setup.py index d8526e53e..f1a7abbff 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,6 @@ import subprocess import sys import warnings from collections.abc import Iterator -from typing import Any from setuptools import Extension, setup from setuptools.command.build_ext import build_ext @@ -46,7 +45,7 @@ WEBP_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ -if sys.platform == "win32" and sys.version_info >= (3, 14): +if sys.platform == "win32" and sys.version_info >= (3, 15): import atexit atexit.register( @@ -148,7 +147,7 @@ class RequiredDependencyException(Exception): PLATFORM_MINGW = os.name == "nt" and "GCC" in sys.version -def _dbg(s: str, tp: Any = None) -> None: +def _dbg(s: str, tp: str | tuple[str, ...] | None = None) -> None: if DEBUG: if tp: print(s % tp) @@ -163,7 +162,7 @@ def _find_library_dirs_ldconfig() -> list[str]: args: list[str] env: dict[str, str] expr: str - if sys.platform.startswith("linux") or sys.platform.startswith("gnu"): + if sys.platform.startswith(("linux", "gnu")): if struct.calcsize("l") == 4: machine = os.uname()[4] + "-32" else: @@ -224,13 +223,14 @@ def _add_directory( path.insert(where, subdir) -def _find_include_file(self: pil_build_ext, include: str) -> int: +def _find_include_file(self: pil_build_ext, include: str) -> str | None: for directory in self.compiler.include_dirs: _dbg("Checking for include file %s in %s", (include, directory)) - if os.path.isfile(os.path.join(directory, include)): + path = os.path.join(directory, include) + if os.path.isfile(path): _dbg("Found %s", include) - return 1 - return 0 + return path + return None def _find_library_file(self: pil_build_ext, library: str) -> str | None: @@ -509,11 +509,11 @@ class pil_build_ext(build_ext): if root is None and pkg_config: if isinstance(lib_name, str): - _dbg(f"Looking for `{lib_name}` using pkg-config.") + _dbg("Looking for `%s` using pkg-config.", lib_name) root = pkg_config(lib_name) else: for lib_name2 in lib_name: - _dbg(f"Looking for `{lib_name2}` using pkg-config.") + _dbg("Looking for `%s` using pkg-config.", lib_name2) root = pkg_config(lib_name2) if root: break @@ -623,11 +623,7 @@ class pil_build_ext(build_ext): for extension in self.extensions: extension.extra_compile_args = ["-Wno-nullability-completeness"] - elif ( - sys.platform.startswith("linux") - or sys.platform.startswith("gnu") - or sys.platform.startswith("freebsd") - ): + elif sys.platform.startswith(("linux", "gnu", "freebsd")): for dirname in _find_library_dirs_ldconfig(): _add_directory(library_dirs, dirname) if sys.platform.startswith("linux") and os.environ.get("ANDROID_ROOT"): @@ -736,7 +732,7 @@ class pil_build_ext(build_ext): best_path = os.path.join(directory, name) _dbg( "Best openjpeg version %s so far in %s", - (best_version, best_path), + (str(best_version), best_path), ) if best_version and _find_library_file(self, "openjp2"): @@ -766,12 +762,12 @@ class pil_build_ext(build_ext): if feature.want("tiff"): _dbg("Looking for tiff") if _find_include_file(self, "tiff.h"): - if _find_library_file(self, "tiff"): - feature.set("tiff", "tiff") if sys.platform in ["win32", "darwin"] and _find_library_file( self, "libtiff" ): feature.set("tiff", "libtiff") + elif _find_library_file(self, "tiff"): + feature.set("tiff", "tiff") if feature.want("freetype"): _dbg("Looking for freetype") @@ -861,9 +857,13 @@ class pil_build_ext(build_ext): if feature.want("avif"): _dbg("Looking for avif") - if _find_include_file(self, "avif/avif.h"): - if _find_library_file(self, "avif"): - feature.set("avif", "avif") + if avif_h := _find_include_file(self, "avif/avif.h"): + with open(avif_h, "rb") as fp: + major_version = int( + fp.read().split(b"#define AVIF_VERSION_MAJOR ")[1].split()[0] + ) + if major_version >= 1 and _find_library_file(self, "avif"): + feature.set("avif", "avif") for f in feature: if not feature.get(f) and feature.require(f): diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index b2c5ab15d..366e0c864 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -16,7 +16,6 @@ except ImportError: # Decoder options as module globals, until there is a way to pass parameters # to Image.open (see https://github.com/python-pillow/Pillow/issues/569) DECODE_CODEC_CHOICE = "auto" -# Decoding is only affected by this for libavif **0.8.4** or greater. DEFAULT_MAX_THREADS = 0 diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 26307817c..f9ade18f9 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -1,5 +1,5 @@ """ -A Pillow loader for .dds files (S3TC-compressed aka DXTC) +A Pillow plugin for .dds files (S3TC-compressed aka DXTC) Jerome Leclanche Documentation: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 4392c4cb9..c98e02f69 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -31,7 +31,7 @@ import os import subprocess from enum import IntEnum from functools import cached_property -from typing import IO, Any, Literal, NamedTuple, Union +from typing import IO, Any, Literal, NamedTuple, Union, cast from . import ( Image, @@ -350,12 +350,15 @@ class GifImageFile(ImageFile.ImageFile): if self._frame_palette: if color * 3 + 3 > len(self._frame_palette.palette): color = 0 - return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) + return cast( + tuple[int, int, int], + tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]), + ) else: return (color, color, color) self.dispose = None - self.dispose_extent = frame_dispose_extent + self.dispose_extent: tuple[int, int, int, int] | None = frame_dispose_extent if self.dispose_extent and self.disposal_method >= 2: try: if self.disposal_method == 2: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index ded40bc5d..0be9e8ce4 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -767,18 +767,20 @@ class Image: .. warning:: - This method returns the raw image data from the internal - storage. For compressed image data (e.g. PNG, JPEG) use - :meth:`~.save`, with a BytesIO parameter for in-memory - data. + This method returns raw image data derived from Pillow's internal + storage. For compressed image data (e.g. PNG, JPEG) use + :meth:`~.save`, with a BytesIO parameter for in-memory data. - :param encoder_name: What encoder to use. The default is to - use the standard "raw" encoder. + :param encoder_name: What encoder to use. - A list of C encoders can be seen under - codecs section of the function array in - :file:`_imaging.c`. Python encoders are - registered within the relevant plugins. + The default is to use the standard "raw" encoder. + To see how this packs pixel data into the returned + bytes, see :file:`libImaging/Pack.c`. + + A list of C encoders can be seen under codecs + section of the function array in + :file:`_imaging.c`. Python encoders are registered + within the relevant plugins. :param args: Extra arguments to the encoder. :returns: A :py:class:`bytes` object. """ @@ -800,7 +802,9 @@ class Image: e = _getencoder(self.mode, encoder_name, encoder_args) e.setimage(self.im) - bufsize = max(65536, self.size[0] * 4) # see RawEncode.c + from . import ImageFile + + bufsize = max(ImageFile.MAXBLOCK, self.size[0] * 4) # see RawEncode.c output = [] while True: @@ -1507,7 +1511,7 @@ class Image: return {} if "xmp" not in self.info: return {} - root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00")) + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00 ")) return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: @@ -1538,10 +1542,11 @@ class Image: # XMP tags if ExifTags.Base.Orientation not in self._exif: xmp_tags = self.info.get("XML:com.adobe.xmp") + pattern: str | bytes = r'tiff:Orientation(="|>)([0-9])' if not xmp_tags and (xmp_tags := self.info.get("xmp")): - xmp_tags = xmp_tags.decode("utf-8") + pattern = rb'tiff:Orientation(="|>)([0-9])' if xmp_tags: - match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) + match = re.search(pattern, xmp_tags) if match: self._exif[ExifTags.Base.Orientation] = int(match[2]) @@ -3267,7 +3272,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: :param obj: Object with array interface :param mode: Optional mode to use when reading ``obj``. Will be determined from - type if ``None``. + type if ``None``. Deprecated. This will not be used to convert the data after reading, but will be used to change how the data is read:: @@ -3302,6 +3307,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: msg = f"Cannot handle this data type: {typekey_shape}, {typestr}" raise TypeError(msg) from e else: + deprecate("'mode' parameter", 13) rawmode = mode if mode in ["1", "L", "I", "P", "F"]: ndmax = 2 diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index fdfbee789..a1584f111 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -248,6 +248,9 @@ class ImageCmsProfile: low-level profile object """ + self.filename = None + self.product_name = None # profile.product_name + self.product_info = None # profile.product_info if isinstance(profile, str): if sys.platform == "win32": @@ -256,23 +259,18 @@ class ImageCmsProfile: profile_bytes_path.decode("ascii") except UnicodeDecodeError: with open(profile, "rb") as f: - self._set(core.profile_frombytes(f.read())) + self.profile = core.profile_frombytes(f.read()) return - self._set(core.profile_open(profile), profile) + self.filename = profile + self.profile = core.profile_open(profile) elif hasattr(profile, "read"): - self._set(core.profile_frombytes(profile.read())) + self.profile = core.profile_frombytes(profile.read()) elif isinstance(profile, core.CmsProfile): - self._set(profile) + self.profile = profile else: msg = "Invalid type for Profile" # type: ignore[unreachable] raise TypeError(msg) - def _set(self, profile: core.CmsProfile, filename: str | None = None) -> None: - self.profile = profile - self.filename = filename - self.product_name = None # profile.product_name - self.product_info = None # profile.product_info - def tobytes(self) -> bytes: """ Returns the profile in a format suitable for embedding in diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e6c7b0298..98ae67539 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -365,22 +365,10 @@ class ImageDraw: # use the fill as a mask mask = Image.new("1", self.im.size) mask_ink = self._getink(1)[0] - - fill_im = mask.copy() - draw = Draw(fill_im) + draw = Draw(mask) draw.draw.draw_polygon(xy, mask_ink, 1) - ink_im = mask.copy() - draw = Draw(ink_im) - width = width * 2 - 1 - draw.draw.draw_polygon(xy, mask_ink, 0, width) - - mask.paste(ink_im, mask=fill_im) - - im = Image.new(self.mode, self.im.size) - draw = Draw(im) - draw.draw.draw_polygon(xy, ink, 0, width) - self.im.paste(im.im, (0, 0) + im.size, mask.im) + self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) def regular_polygon( self, diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index bcb7d462e..bf556a2c6 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -257,7 +257,8 @@ class ImageFile(Image.Image): def __setstate__(self, state: list[Any]) -> None: self.tile = [] - self.filename = state[5] + if len(state) > 5: + self.filename = state[5] super().__setstate__(state) def verify(self) -> None: diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index c29350b7a..1eb450734 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -134,10 +134,10 @@ def grabclipboard() -> Image.Image | list[str] | None: import struct o = struct.unpack_from("I", data)[0] - if data[16] != 0: - files = data[o:].decode("utf-16le").split("\0") - else: + if data[16] == 0: files = data[o:].decode("mbcs").split("\0") + else: + files = data[o:].decode("utf-16le").split("\0") return files[: files.index("")] if isinstance(data, bytes): data = io.BytesIO(data) diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index dd240fb55..7705608e3 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -175,7 +175,9 @@ class MacViewer(Viewer): if not os.path.exists(path): raise FileNotFoundError subprocess.call(["open", "-a", "Preview.app", path]) - executable = sys.executable or shutil.which("python3") + + pyinstaller = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS") + executable = (not pyinstaller and sys.executable) or shutil.which("python3") if executable: subprocess.Popen( [ diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 969528841..defe9f773 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -762,8 +762,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: extra = info.get("extra", b"") MAX_BYTES_IN_MARKER = 65533 - xmp = info.get("xmp") - if xmp: + if xmp := info.get("xmp"): overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len if len(xmp) > max_data_bytes_in_marker: @@ -772,8 +771,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: size = o16(2 + overhead_len + len(xmp)) extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp - icc_profile = info.get("icc_profile") - if icc_profile: + if icc_profile := info.get("icc_profile"): overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len markers = [] @@ -831,7 +829,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # in a shot. Guessing on the size, at im.size bytes. (raw pixel size is # channels*size, this is a value that's been used in a django patch. # https://github.com/matthewwithanm/django-imagekit/issues/50 - bufsize = 0 if optimize or progressive: # CMYK can be bigger if im.mode == "CMYK": @@ -848,7 +845,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: else: # The EXIF info needs to be written as one block, + APP1, + one spare byte. # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(bufsize, len(exif) + 5, len(extra) + 1) + bufsize = max(len(exif) + 5, len(extra) + 1) ImageFile._save( im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize diff --git a/src/PIL/MpegImagePlugin.py b/src/PIL/MpegImagePlugin.py index 5aa00d05b..47ebe9d62 100644 --- a/src/PIL/MpegImagePlugin.py +++ b/src/PIL/MpegImagePlugin.py @@ -33,11 +33,7 @@ class BitStream: def peek(self, bits: int) -> int: while self.bits < bits: - c = self.next() - if c < 0: - self.bits = 0 - continue - self.bitbuffer = (self.bitbuffer << 8) + c + self.bitbuffer = (self.bitbuffer << 8) + self.next() self.bits += 8 return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1 diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 299405ae0..458d586c4 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -54,7 +54,7 @@ class PcxImageFile(ImageFile.ImageFile): # header assert self.fp is not None - s = self.fp.read(128) + s = self.fp.read(68) if not _accept(s): msg = "not a PCX file" raise SyntaxError(msg) @@ -66,6 +66,8 @@ class PcxImageFile(ImageFile.ImageFile): raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) + offset = self.fp.tell() + 60 + # format version = s[1] bits = s[3] @@ -102,7 +104,6 @@ class PcxImageFile(ImageFile.ImageFile): break if mode == "P": self.palette = ImagePalette.raw("RGB", s[1:]) - self.fp.seek(128) elif version == 5 and bits == 8 and planes == 3: mode = "RGB" @@ -128,9 +129,7 @@ class PcxImageFile(ImageFile.ImageFile): bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [ - ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) - ] + self.tile = [ImageFile._Tile("pcx", bbox, offset, (rawmode, planes * stride))] # -------------------------------------------------------------------- diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index f3815a122..1b9a89aef 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -48,6 +48,7 @@ from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +from ._deprecate import deprecate from ._util import DeferredError TYPE_CHECKING = False @@ -1368,6 +1369,8 @@ def _save( except KeyError as e: msg = f"cannot write mode {mode} as PNG" raise OSError(msg) from e + if outmode == "I": + deprecate("Saving I mode images as PNG", 13, stacklevel=4) # # write minimal PNG file diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 03afa2d2e..db34d107a 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -94,8 +94,8 @@ class PpmImageFile(ImageFile.ImageFile): msg = "Reached EOF while reading header" raise ValueError(msg) elif len(token) > 10: - msg = f"Token too long in file header: {token.decode()}" - raise ValueError(msg) + msg_too_long = b"Token too long in file header: %s" % token + raise ValueError(msg_too_long) return token def _open(self) -> None: diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index df552243e..dba5d809f 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -8,9 +8,12 @@ from __future__ import annotations import os +from typing import IO from . import Image, ImageFile from ._binary import i32be as i32 +from ._binary import o8 +from ._binary import o32be as o32 def _accept(prefix: bytes) -> bool: @@ -51,7 +54,7 @@ class QoiDecoder(ImageFile.PyDecoder): assert self.fd is not None self._previously_seen_pixels = {} - self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) + self._previous_pixel = bytearray((0, 0, 0, 255)) data = bytearray() bands = Image.getmodebands(self.mode) @@ -110,6 +113,122 @@ class QoiDecoder(ImageFile.PyDecoder): return -1, 0 +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode == "RGB": + channels = 3 + elif im.mode == "RGBA": + channels = 4 + else: + msg = "Unsupported QOI image mode" + raise ValueError(msg) + + colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1 + + fp.write(b"qoif") + fp.write(o32(im.size[0])) + fp.write(o32(im.size[1])) + fp.write(o8(channels)) + fp.write(o8(colorspace)) + + ImageFile._save(im, fp, [ImageFile._Tile("qoi", (0, 0) + im.size)]) + + +class QoiEncoder(ImageFile.PyEncoder): + _pushes_fd = True + _previous_pixel: tuple[int, int, int, int] | None = None + _previously_seen_pixels: dict[int, tuple[int, int, int, int]] = {} + _run = 0 + + def _write_run(self) -> bytes: + data = o8(0b11000000 | (self._run - 1)) # QOI_OP_RUN + self._run = 0 + return data + + def _delta(self, left: int, right: int) -> int: + result = (left - right) & 255 + if result >= 128: + result -= 256 + return result + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + assert self.im is not None + + self._previously_seen_pixels = {0: (0, 0, 0, 0)} + self._previous_pixel = (0, 0, 0, 255) + + data = bytearray() + w, h = self.im.size + bands = Image.getmodebands(self.mode) + + for y in range(h): + for x in range(w): + pixel = self.im.getpixel((x, y)) + if bands == 3: + pixel = (*pixel, 255) + + if pixel == self._previous_pixel: + self._run += 1 + if self._run == 62: + data += self._write_run() + else: + if self._run: + data += self._write_run() + + r, g, b, a = pixel + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + if self._previously_seen_pixels.get(hash_value) == pixel: + data += o8(hash_value) # QOI_OP_INDEX + elif self._previous_pixel: + self._previously_seen_pixels[hash_value] = pixel + + prev_r, prev_g, prev_b, prev_a = self._previous_pixel + if prev_a == a: + delta_r = self._delta(r, prev_r) + delta_g = self._delta(g, prev_g) + delta_b = self._delta(b, prev_b) + + if ( + -2 <= delta_r < 2 + and -2 <= delta_g < 2 + and -2 <= delta_b < 2 + ): + data += o8( + 0b01000000 + | (delta_r + 2) << 4 + | (delta_g + 2) << 2 + | (delta_b + 2) + ) # QOI_OP_DIFF + else: + delta_gr = self._delta(delta_r, delta_g) + delta_gb = self._delta(delta_b, delta_g) + if ( + -8 <= delta_gr < 8 + and -32 <= delta_g < 32 + and -8 <= delta_gb < 8 + ): + data += o8( + 0b10000000 | (delta_g + 32) + ) # QOI_OP_LUMA + data += o8((delta_gr + 8) << 4 | (delta_gb + 8)) + else: + data += o8(0b11111110) # QOI_OP_RGB + data += bytes(pixel[:3]) + else: + data += o8(0b11111111) # QOI_OP_RGBA + data += bytes(pixel) + + self._previous_pixel = pixel + + if self._run: + data += self._write_run() + data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding + + return len(data), 0, data + + Image.register_open(QoiImageFile.format, QoiImageFile, _accept) Image.register_decoder("qoi", QoiDecoder) Image.register_extension(QoiImageFile.format, ".qoi") + +Image.register_save(QoiImageFile.format, _save) +Image.register_encoder("qoi", QoiEncoder) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 88af9162e..146b01e5f 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1217,9 +1217,10 @@ class TiffImageFile(ImageFile.ImageFile): return self._seek(frame) if self._im is not None and ( - self.im.size != self._tile_size or self.im.mode != self.mode + self.im.size != self._tile_size + or self.im.mode != self.mode + or self.readonly ): - # The core image will no longer be used self._im = None def _seek(self, frame: int) -> None: @@ -1259,7 +1260,10 @@ class TiffImageFile(ImageFile.ImageFile): self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) if XMP in self.tag_v2: - self.info["xmp"] = self.tag_v2[XMP] + xmp = self.tag_v2[XMP] + if isinstance(xmp, tuple) and len(xmp) == 1: + xmp = xmp[0] + self.info["xmp"] = xmp elif "xmp" in self.info: del self.info["xmp"] self._reload_exif() @@ -1676,7 +1680,7 @@ SAVE_INFO = { "PA": ("PA", II, 3, 1, (8, 8), 2), "I": ("I;32S", II, 1, 2, (32,), None), "I;16": ("I;16", II, 1, 1, (16,), None), - "I;16S": ("I;16S", II, 1, 2, (16,), None), + "I;16L": ("I;16L", II, 1, 1, (16,), None), "F": ("F;32F", II, 1, 3, (32,), None), "RGB": ("RGB", II, 2, 1, (8, 8, 8), None), "RGBX": ("RGBX", II, 2, 1, (8, 8, 8, 8), 0), @@ -1684,10 +1688,7 @@ SAVE_INFO = { "CMYK": ("CMYK", II, 5, 1, (8, 8, 8, 8), None), "YCbCr": ("YCbCr", II, 6, 1, (8, 8, 8), None), "LAB": ("LAB", II, 8, 1, (8, 8, 8), None), - "I;32BS": ("I;32BS", MM, 1, 2, (32,), None), "I;16B": ("I;16B", MM, 1, 1, (16,), None), - "I;16BS": ("I;16BS", MM, 1, 2, (16,), None), - "F;32BF": ("F;32BF", MM, 1, 3, (32,), None), } @@ -1963,7 +1964,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # we're storing image byte order. So, if the rawmode # contains I;16, we need to convert from native to image # byte order. - if im.mode in ("I;16B", "I;16"): + if im.mode in ("I;16", "I;16B", "I;16L"): rawmode = "I;16N" # Pass tags as sorted list so that the tags are set in a fixed order. diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index f709d026b..d569cb4b8 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -81,7 +81,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _open(self) -> None: # check placable header - s = self.fp.read(80) + s = self.fp.read(44) if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"): # placeable windows metafile diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 9f9d8bbc9..170d44490 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -12,6 +12,7 @@ def deprecate( *, action: str | None = None, plural: bool = False, + stacklevel: int = 3, ) -> None: """ Deprecations helper. @@ -67,5 +68,5 @@ def deprecate( warnings.warn( f"{deprecated} {is_} deprecated and will be removed in {removed}{action}", DeprecationWarning, - stacklevel=3, + stacklevel=stacklevel, ) diff --git a/src/_avif.c b/src/_avif.c index 7e7bee703..3585297fe 100644 --- a/src/_avif.c +++ b/src/_avif.c @@ -881,26 +881,22 @@ setup_module(PyObject *m) { return 0; } +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, setup_module}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__avif(void) { - PyObject *m; - static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_avif", - .m_size = -1, .m_methods = avifMethods, + .m_slots = slots }; - m = PyModule_Create(&module_def); - if (setup_module(m) < 0) { - Py_DECREF(m); - return NULL; - } - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_imaging.c b/src/_imaging.c index 72f122143..6f13834a9 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -308,9 +308,9 @@ _new_arrow(PyObject *self, PyObject *args) { } // ImagingBorrowArrow is responsible for retaining the array_capsule - ret = - PyImagingNew(ImagingNewArrow(mode, xsize, ysize, schema_capsule, array_capsule) - ); + ret = PyImagingNew( + ImagingNewArrow(mode, xsize, ysize, schema_capsule, array_capsule) + ); if (!ret) { return ImagingError_ValueError("Invalid Arrow array mode or size mismatch"); } @@ -338,12 +338,6 @@ static const char *no_palette = "image has no palette"; static const char *readonly = "image is readonly"; /* static const char* no_content = "image has no content"; */ -void * -ImagingError_OSError(void) { - PyErr_SetString(PyExc_OSError, "error when accessing file"); - return NULL; -} - void * ImagingError_MemoryError(void) { return PyErr_NoMemory(); @@ -369,11 +363,6 @@ ImagingError_ValueError(const char *message) { return NULL; } -void -ImagingError_Clear(void) { - PyErr_Clear(); -} - /* -------------------------------------------------------------------- */ /* HELPERS */ /* -------------------------------------------------------------------- */ @@ -1665,7 +1654,8 @@ _putdata(ImagingObject *self, PyObject *args) { int bigendian = 0; if (image->type == IMAGING_TYPE_SPECIAL) { // I;16* - if (strcmp(image->mode, "I;16B") == 0 + if ( + strcmp(image->mode, "I;16B") == 0 #ifdef WORDS_BIGENDIAN || strcmp(image->mode, "I;16N") == 0 #endif @@ -2226,6 +2216,7 @@ _unsharp_mask(ImagingObject *self, PyObject *args) { } if (!ImagingUnsharpMask(imOut, imIn, radius, percent, threshold)) { + ImagingDelete(imOut); return NULL; } @@ -3218,7 +3209,8 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { (int)p[3], &ink, width, - self->blend + self->blend, + NULL ) < 0) { free(xy); return NULL; @@ -3356,7 +3348,10 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) { int ink; int fill = 0; int width = 0; - if (!PyArg_ParseTuple(args, "Oi|ii", &data, &ink, &fill, &width)) { + ImagingObject *maskp = NULL; + if (!PyArg_ParseTuple( + args, "Oi|iiO!", &data, &ink, &fill, &width, &Imaging_Type, &maskp + )) { return NULL; } @@ -3386,8 +3381,16 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) { free(xy); - if (ImagingDrawPolygon(self->image->image, n, ixy, &ink, fill, width, self->blend) < - 0) { + if (ImagingDrawPolygon( + self->image->image, + n, + ixy, + &ink, + fill, + width, + self->blend, + maskp ? maskp->image : NULL + ) < 0) { free(ixy); return NULL; } @@ -4460,27 +4463,22 @@ setup_module(PyObject *m) { return 0; } +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, setup_module}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__imaging(void) { - PyObject *m; - static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_imaging", - .m_size = -1, .m_methods = functions, + .m_slots = slots }; - m = PyModule_Create(&module_def); - - if (setup_module(m) < 0) { - Py_DECREF(m); - return NULL; - } - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_imagingcms.c b/src/_imagingcms.c index f93c1613b..e2f29d1b7 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -1463,28 +1463,24 @@ setup_module(PyObject *m) { return 0; } +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, setup_module}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__imagingcms(void) { - PyObject *m; + PyDateTime_IMPORT; static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_imagingcms", - .m_size = -1, .m_methods = pyCMSdll_methods, + .m_slots = slots }; - m = PyModule_Create(&module_def); - - if (setup_module(m) < 0) { - return NULL; - } - - PyDateTime_IMPORT; - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_imagingft.c b/src/_imagingft.c index 62dab73e5..29d8e9e71 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -275,6 +275,7 @@ text_layout_raqm( if (!text || !size) { /* return 0 and clean up, no glyphs==no size, and raqm fails with empty strings */ + PyMem_Free(text); goto failed; } set_text = raqm_set_text(rq, text, size); @@ -425,6 +426,7 @@ text_layout_fallback( "setting text direction, language or font features is not supported " "without libraqm" ); + return 0; } if (PyUnicode_Check(string)) { @@ -1599,26 +1601,22 @@ setup_module(PyObject *m) { return 0; } +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, setup_module}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__imagingft(void) { - PyObject *m; - static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_imagingft", - .m_size = -1, .m_methods = _functions, + .m_slots = slots }; - m = PyModule_Create(&module_def); - - if (setup_module(m) < 0) { - return NULL; - } - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_imagingmath.c b/src/_imagingmath.c index 4b9bf08ba..48c395900 100644 --- a/src/_imagingmath.c +++ b/src/_imagingmath.c @@ -302,26 +302,22 @@ setup_module(PyObject *m) { return 0; } +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, setup_module}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__imagingmath(void) { - PyObject *m; - static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_imagingmath", - .m_size = -1, .m_methods = _functions, + .m_slots = slots }; - m = PyModule_Create(&module_def); - - if (setup_module(m) < 0) { - return NULL; - } - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_imagingmorph.c b/src/_imagingmorph.c index a20888294..5995f9d42 100644 --- a/src/_imagingmorph.c +++ b/src/_imagingmorph.c @@ -246,23 +246,22 @@ static PyMethodDef functions[] = { {NULL, NULL, 0, NULL} }; +static PyModuleDef_Slot slots[] = { +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__imagingmorph(void) { - PyObject *m; - static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_imagingmorph", .m_doc = "A module for doing image morphology", - .m_size = -1, .m_methods = functions, + .m_slots = slots }; - m = PyModule_Create(&module_def); - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_imagingtk.c b/src/_imagingtk.c index 4e06fe9b8..68d7bf4cd 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -46,24 +46,22 @@ static PyMethodDef functions[] = { {NULL, NULL} /* sentinel */ }; +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, load_tkinter_funcs}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__imagingtk(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_imagingtk", - .m_size = -1, .m_methods = functions, + .m_slots = slots }; - PyObject *m; - m = PyModule_Create(&module_def); - if (load_tkinter_funcs() != 0) { - Py_DECREF(m); - return NULL; - } -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/_webp.c b/src/_webp.c index 3aa4c408b..e84e786ed 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -641,6 +641,10 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { ImagingSectionLeave(&cookie); WebPPictureFree(&pic); + + output = writer.mem; + ret_size = writer.size; + if (!ok) { int error_code = (&pic)->error_code; char message[50] = ""; @@ -652,10 +656,9 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { ); } PyErr_Format(PyExc_ValueError, "encoding error %d%s", error_code, message); + free(output); return NULL; } - output = writer.mem; - ret_size = writer.size; { /* I want to truncate the *_size items that get passed into WebP @@ -777,26 +780,22 @@ setup_module(PyObject *m) { return 0; } +static PyModuleDef_Slot slots[] = { + {Py_mod_exec, setup_module}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + PyMODINIT_FUNC PyInit__webp(void) { - PyObject *m; - static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, .m_name = "_webp", - .m_size = -1, .m_methods = webpMethods, + .m_slots = slots }; - m = PyModule_Create(&module_def); - if (setup_module(m) < 0) { - Py_DECREF(m); - return NULL; - } - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - - return m; + return PyModuleDef_Init(&module_def); } diff --git a/src/display.c b/src/display.c index 11742a895..3215f6691 100644 --- a/src/display.c +++ b/src/display.c @@ -327,11 +327,11 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) { // added in Windows 10 (1607) // loaded dynamically to avoid link errors user32 = LoadLibraryA("User32.dll"); - SetThreadDpiAwarenessContext_function = (Func_SetThreadDpiAwarenessContext - )GetProcAddress(user32, "SetThreadDpiAwarenessContext"); + SetThreadDpiAwarenessContext_function = (Func_SetThreadDpiAwarenessContext) + GetProcAddress(user32, "SetThreadDpiAwarenessContext"); if (SetThreadDpiAwarenessContext_function != NULL) { - GetWindowDpiAwarenessContext_function = (Func_GetWindowDpiAwarenessContext - )GetProcAddress(user32, "GetWindowDpiAwarenessContext"); + GetWindowDpiAwarenessContext_function = (Func_GetWindowDpiAwarenessContext) + GetProcAddress(user32, "GetWindowDpiAwarenessContext"); if (screens == -1 && GetWindowDpiAwarenessContext_function != NULL) { dpiAwareness = GetWindowDpiAwarenessContext_function(wnd); } diff --git a/src/encode.c b/src/encode.c index 7c365a74f..e56494036 100644 --- a/src/encode.c +++ b/src/encode.c @@ -703,6 +703,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { return NULL; } + encoder->cleanup = ImagingLibTiffEncodeCleanup; + num_core_tags = sizeof(core_tags) / sizeof(int); for (pos = 0; pos < tags_size; pos++) { item = PyList_GetItemRef(tags, pos); diff --git a/src/libImaging/Arrow.c b/src/libImaging/Arrow.c index 33ff2ce77..ccafe33b9 100644 --- a/src/libImaging/Arrow.c +++ b/src/libImaging/Arrow.c @@ -36,7 +36,10 @@ ReleaseExportedSchema(struct ArrowSchema *array) { child->release(child); child->release = NULL; } - // UNDONE -- should I be releasing the children? + free(array->children[i]); + } + if (array->children) { + free(array->children); } // Release dictionary @@ -98,7 +101,7 @@ export_imaging_schema(Imaging im, struct ArrowSchema *schema) { } /* for now, single block images */ - if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + if (im->blocks_count > 1) { return IMAGING_ARROW_MEMORY_LAYOUT; } @@ -117,6 +120,7 @@ export_imaging_schema(Imaging im, struct ArrowSchema *schema) { retval = export_named_type(schema->children[0], im->arrow_band_format, "pixel"); if (retval != 0) { free(schema->children[0]); + free(schema->children); schema->release(schema); return retval; } @@ -127,9 +131,7 @@ static void release_const_array(struct ArrowArray *array) { Imaging im = (Imaging)array->private_data; - if (array->n_children == 0) { - ImagingDelete(im); - } + ImagingDelete(im); // Free the buffers and the buffers array if (array->buffers) { @@ -157,7 +159,7 @@ export_single_channel_array(Imaging im, struct ArrowArray *array) { int length = im->xsize * im->ysize; /* for now, single block images */ - if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + if (im->blocks_count > 1) { return IMAGING_ARROW_MEMORY_LAYOUT; } @@ -200,7 +202,7 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { int length = im->xsize * im->ysize; /* for now, single block images */ - if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + if (im->blocks_count > 1) { return IMAGING_ARROW_MEMORY_LAYOUT; } diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index d5aff8709..27cac687e 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -63,7 +63,7 @@ typedef struct { } Edge; /* Type used in "polygon*" functions */ -typedef void (*hline_handler)(Imaging, int, int, int, int); +typedef void (*hline_handler)(Imaging, int, int, int, int, Imaging); static inline void point8(Imaging im, int x, int y, int ink) { @@ -103,9 +103,7 @@ point32rgba(Imaging im, int x, int y, int ink) { } static inline void -hline8(Imaging im, int x0, int y0, int x1, int ink) { - int pixelwidth; - +hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { if (y0 >= 0 && y0 < im->ysize) { if (x0 < 0) { x0 = 0; @@ -118,16 +116,41 @@ hline8(Imaging im, int x0, int y0, int x1, int ink) { x1 = im->xsize - 1; } if (x0 <= x1) { - pixelwidth = strncmp(im->mode, "I;16", 4) == 0 ? 2 : 1; - memset( - im->image8[y0] + x0 * pixelwidth, (UINT8)ink, (x1 - x0 + 1) * pixelwidth - ); + int bigendian = -1; + if (strncmp(im->mode, "I;16", 4) == 0) { + bigendian = + ( +#ifdef WORDS_BIGENDIAN + strcmp(im->mode, "I;16") == 0 || strcmp(im->mode, "I;16L") == 0 +#else + strcmp(im->mode, "I;16B") == 0 +#endif + ) + ? 1 + : 0; + } + if (mask == NULL && bigendian == -1) { + memset(im->image8[y0] + x0, (UINT8)ink, (x1 - x0 + 1)); + } else { + UINT8 *p = im->image8[y0]; + while (x0 <= x1) { + if (mask == NULL || mask->image8[y0][x0]) { + if (bigendian == -1) { + p[x0] = ink; + } else { + p[x0 * 2 + (bigendian ? 1 : 0)] = ink; + p[x0 * 2 + (bigendian ? 0 : 1)] = ink >> 8; + } + } + x0++; + } + } } } } static inline void -hline32(Imaging im, int x0, int y0, int x1, int ink) { +hline32(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { INT32 *p; if (y0 >= 0 && y0 < im->ysize) { @@ -143,13 +166,16 @@ hline32(Imaging im, int x0, int y0, int x1, int ink) { } p = im->image32[y0]; while (x0 <= x1) { - p[x0++] = ink; + if (mask == NULL || mask->image8[y0][x0]) { + p[x0] = ink; + } + x0++; } } } static inline void -hline32rgba(Imaging im, int x0, int y0, int x1, int ink) { +hline32rgba(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { unsigned int tmp; if (y0 >= 0 && y0 < im->ysize) { @@ -167,9 +193,11 @@ hline32rgba(Imaging im, int x0, int y0, int x1, int ink) { UINT8 *out = (UINT8 *)im->image[y0] + x0 * 4; UINT8 *in = (UINT8 *)&ink; while (x0 <= x1) { - out[0] = BLEND(in[3], out[0], in[0], tmp); - out[1] = BLEND(in[3], out[1], in[1], tmp); - out[2] = BLEND(in[3], out[2], in[2], tmp); + if (mask == NULL || mask->image8[y0][x0]) { + out[0] = BLEND(in[3], out[0], in[0], tmp); + out[1] = BLEND(in[3], out[1], in[1], tmp); + out[2] = BLEND(in[3], out[2], in[2], tmp); + } x0++; out += 4; } @@ -407,7 +435,14 @@ x_cmp(const void *x0, const void *x1) { static void draw_horizontal_lines( - Imaging im, int n, Edge *e, int ink, int *x_pos, int y, hline_handler hline + Imaging im, + int n, + Edge *e, + int ink, + int *x_pos, + int y, + hline_handler hline, + Imaging mask ) { int i; for (i = 0; i < n; i++) { @@ -429,7 +464,7 @@ draw_horizontal_lines( } } - (*hline)(im, xmin, e[i].ymin, xmax, ink); + (*hline)(im, xmin, e[i].ymin, xmax, ink, mask); *x_pos = xmax + 1; } } @@ -440,7 +475,7 @@ draw_horizontal_lines( */ static inline int polygon_generic( - Imaging im, int n, Edge *e, int ink, int eofill, hline_handler hline, int hasAlpha + Imaging im, int n, Edge *e, int ink, int eofill, hline_handler hline, Imaging mask ) { Edge **edge_table; float *xx; @@ -461,6 +496,7 @@ polygon_generic( return -1; } + int hasAlpha = hline == hline32rgba; for (i = 0; i < n; i++) { if (ymin > e[i].ymin) { ymin = e[i].ymin; @@ -470,7 +506,7 @@ polygon_generic( } if (e[i].ymin == e[i].ymax) { if (hasAlpha != 1) { - (*hline)(im, e[i].xmin, e[i].ymin, e[i].xmax, ink); + (*hline)(im, e[i].xmin, e[i].ymin, e[i].xmax, ink, mask); } continue; } @@ -558,7 +594,7 @@ polygon_generic( // Line would be before the current position continue; } - draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline); + draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline, mask); if (x_end < x_pos) { // Line would be before the current position continue; @@ -574,13 +610,13 @@ polygon_generic( continue; } } - (*hline)(im, x_start, ymin, x_end, ink); + (*hline)(im, x_start, ymin, x_end, ink, mask); x_pos = x_end + 1; } - draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline); + draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline, mask); } else { for (i = 1; i < j; i += 2) { - (*hline)(im, ROUND_UP(xx[i - 1]), ymin, ROUND_DOWN(xx[i]), ink); + (*hline)(im, ROUND_UP(xx[i - 1]), ymin, ROUND_DOWN(xx[i]), ink, mask); } } } @@ -590,21 +626,6 @@ polygon_generic( return 0; } -static inline int -polygon8(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline8, 0); -} - -static inline int -polygon32(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline32, 0); -} - -static inline int -polygon32rgba(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline32rgba, 1); -} - static inline void add_edge(Edge *e, int x0, int y0, int x1, int y1) { /* printf("edge %d %d %d %d\n", x0, y0, x1, y1); */ @@ -639,14 +660,13 @@ add_edge(Edge *e, int x0, int y0, int x1, int y1) { typedef struct { void (*point)(Imaging im, int x, int y, int ink); - void (*hline)(Imaging im, int x0, int y0, int x1, int ink); + void (*hline)(Imaging im, int x0, int y0, int x1, int ink, Imaging mask); void (*line)(Imaging im, int x0, int y0, int x1, int y1, int ink); - int (*polygon)(Imaging im, int n, Edge *e, int ink, int eofill); } DRAW; -DRAW draw8 = {point8, hline8, line8, polygon8}; -DRAW draw32 = {point32, hline32, line32, polygon32}; -DRAW draw32rgba = {point32rgba, hline32rgba, line32rgba, polygon32rgba}; +DRAW draw8 = {point8, hline8, line8}; +DRAW draw32 = {point32, hline32, line32}; +DRAW draw32rgba = {point32rgba, hline32rgba, line32rgba}; /* -------------------------------------------------------------------- */ /* Interface */ @@ -691,7 +711,15 @@ ImagingDrawLine(Imaging im, int x0, int y0, int x1, int y1, const void *ink_, in int ImagingDrawWideLine( - Imaging im, int x0, int y0, int x1, int y1, const void *ink_, int width, int op + Imaging im, + int x0, + int y0, + int x1, + int y1, + const void *ink_, + int width, + int op, + Imaging mask ) { DRAW *draw; INT32 ink; @@ -731,7 +759,7 @@ ImagingDrawWideLine( add_edge(e + 2, vertices[2][0], vertices[2][1], vertices[3][0], vertices[3][1]); add_edge(e + 3, vertices[3][0], vertices[3][1], vertices[0][0], vertices[0][1]); - draw->polygon(im, 4, e, ink, 0); + polygon_generic(im, 4, e, ink, 0, draw->hline, mask); } return 0; } @@ -774,7 +802,7 @@ ImagingDrawRectangle( } for (y = y0; y <= y1; y++) { - draw->hline(im, x0, y, x1, ink); + draw->hline(im, x0, y, x1, ink, NULL); } } else { @@ -783,8 +811,8 @@ ImagingDrawRectangle( width = 1; } for (i = 0; i < width; i++) { - draw->hline(im, x0, y0 + i, x1, ink); - draw->hline(im, x0, y1 - i, x1, ink); + draw->hline(im, x0, y0 + i, x1, ink, NULL); + draw->hline(im, x0, y1 - i, x1, ink, NULL); draw->line(im, x1 - i, y0 + width, x1 - i, y1 - width + 1, ink); draw->line(im, x0 + i, y0 + width, x0 + i, y1 - width + 1, ink); } @@ -795,7 +823,14 @@ ImagingDrawRectangle( int ImagingDrawPolygon( - Imaging im, int count, int *xy, const void *ink_, int fill, int width, int op + Imaging im, + int count, + int *xy, + const void *ink_, + int fill, + int width, + int op, + Imaging mask ) { int i, n, x0, y0, x1, y1; DRAW *draw; @@ -839,7 +874,7 @@ ImagingDrawPolygon( if (xy[i * 2] != xy[0] || xy[i * 2 + 1] != xy[1]) { add_edge(&e[n++], xy[i * 2], xy[i * 2 + 1], xy[0], xy[1]); } - draw->polygon(im, n, e, ink, 0); + polygon_generic(im, n, e, ink, 0, draw->hline, mask); free(e); } else { @@ -861,11 +896,12 @@ ImagingDrawPolygon( xy[i * 2 + 3], ink_, width, - op + op, + mask ); } ImagingDrawWideLine( - im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink_, width, op + im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink_, width, op, mask ); } } @@ -1536,7 +1572,9 @@ ellipseNew( ellipse_init(&st, a, b, width); int32_t X0, Y, X1; while (ellipse_next(&st, &X0, &Y, &X1) != -1) { - draw->hline(im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink); + draw->hline( + im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink, NULL + ); } return 0; } @@ -1571,7 +1609,9 @@ clipEllipseNew( int32_t X0, Y, X1; int next_code; while ((next_code = clip_ellipse_next(&st, &X0, &Y, &X1)) >= 0) { - draw->hline(im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink); + draw->hline( + im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink, NULL + ); } clip_ellipse_free(&st); return next_code == -1 ? 0 : -1; @@ -1989,7 +2029,7 @@ ImagingDrawOutline( DRAWINIT(); - draw->polygon(im, outline->count, outline->edges, ink, 0); + polygon_generic(im, outline->count, outline->edges, ink, 0, draw->hline, NULL); return 0; } diff --git a/src/libImaging/File.c b/src/libImaging/File.c index 76d0abccc..901fe83ad 100644 --- a/src/libImaging/File.c +++ b/src/libImaging/File.c @@ -54,7 +54,7 @@ ImagingSavePPM(Imaging im, const char *outfile) { fp = fopen(outfile, "wb"); if (!fp) { - (void)ImagingError_OSError(); + PyErr_SetString(PyExc_OSError, "error when accessing file"); return 0; } diff --git a/src/libImaging/Fill.c b/src/libImaging/Fill.c index 8fb481e7e..28f427370 100644 --- a/src/libImaging/Fill.c +++ b/src/libImaging/Fill.c @@ -118,8 +118,9 @@ ImagingFillRadialGradient(const char *mode) { for (y = 0; y < 256; y++) { for (x = 0; x < 256; x++) { - d = (int - )sqrt((double)((x - 128) * (x - 128) + (y - 128) * (y - 128)) * 2.0); + d = (int)sqrt( + (double)((x - 128) * (x - 128) + (y - 128) * (y - 128)) * 2.0 + ); if (d >= 255) { d = 255; } diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c index 7b7b2e429..c46dd3cd1 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -155,7 +155,8 @@ ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { } else { int bigendian = 0; if (im->type == IMAGING_TYPE_SPECIAL) { - if (strcmp(im->mode, "I;16B") == 0 + if ( + strcmp(im->mode, "I;16B") == 0 #ifdef WORDS_BIGENDIAN || strcmp(im->mode, "I;16N") == 0 #endif @@ -308,7 +309,8 @@ ImagingFilter5x5(Imaging imOut, Imaging im, const float *kernel, float offset) { } else { int bigendian = 0; if (im->type == IMAGING_TYPE_SPECIAL) { - if (strcmp(im->mode, "I;16B") == 0 + if ( + strcmp(im->mode, "I;16B") == 0 #ifdef WORDS_BIGENDIAN || strcmp(im->mode, "I;16N") == 0 #endif diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 234f9943c..bfe67d462 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -270,8 +270,6 @@ ImagingSectionLeave(ImagingSectionCookie *cookie); /* Exceptions */ /* ---------- */ -extern void * -ImagingError_OSError(void); extern void * ImagingError_MemoryError(void); extern void * @@ -280,8 +278,6 @@ extern void * ImagingError_Mismatch(void); /* maps to ValueError by default */ extern void * ImagingError_ValueError(const char *message); -extern void -ImagingError_Clear(void); /* Transform callbacks */ /* ------------------- */ @@ -510,7 +506,15 @@ extern int ImagingDrawLine(Imaging im, int x0, int y0, int x1, int y1, const void *ink, int op); extern int ImagingDrawWideLine( - Imaging im, int x0, int y0, int x1, int y1, const void *ink, int width, int op + Imaging im, + int x0, + int y0, + int x1, + int y1, + const void *ink, + int width, + int op, + Imaging mask ); extern int ImagingDrawPieslice( @@ -530,7 +534,14 @@ extern int ImagingDrawPoint(Imaging im, int x, int y, const void *ink, int op); extern int ImagingDrawPolygon( - Imaging im, int points, int *xy, const void *ink, int fill, int width, int op + Imaging im, + int points, + int *xy, + const void *ink, + int fill, + int width, + int op, + Imaging mask ); extern int ImagingDrawRectangle( diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index 34d1a2294..61e095ad6 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -207,8 +207,8 @@ j2k_set_cinema_params(Imaging im, int components, opj_cparameters_t *params) { if (params->cp_cinema == OPJ_CINEMA4K_24) { float max_rate = - ((float)(components * im->xsize * im->ysize * 8) / (CINEMA_24_CS_LENGTH * 8) - ); + ((float)(components * im->xsize * im->ysize * 8) / + (CINEMA_24_CS_LENGTH * 8)); params->POC[0].tile = 1; params->POC[0].resno0 = 0; @@ -243,8 +243,8 @@ j2k_set_cinema_params(Imaging im, int components, opj_cparameters_t *params) { params->max_comp_size = COMP_24_CS_MAX_LENGTH; } else { float max_rate = - ((float)(components * im->xsize * im->ysize * 8) / (CINEMA_48_CS_LENGTH * 8) - ); + ((float)(components * im->xsize * im->ysize * 8) / + (CINEMA_48_CS_LENGTH * 8)); for (n = 0; n < params->tcp_numlayers; ++n) { rate = 0; diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 3c11eac22..79a38e12f 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -131,6 +131,7 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { break; default: state->errcode = IMAGING_CODEC_CONFIG; + jpeg_destroy_compress(&context->cinfo); return -1; } @@ -161,6 +162,7 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { /* Would subsample the green and blue channels, which doesn't make sense */ state->errcode = IMAGING_CODEC_CONFIG; + jpeg_destroy_compress(&context->cinfo); return -1; } jpeg_set_colorspace(&context->cinfo, JCS_RGB); diff --git a/src/libImaging/PcxDecode.c b/src/libImaging/PcxDecode.c index 942c8dc22..a65952fb1 100644 --- a/src/libImaging/PcxDecode.c +++ b/src/libImaging/PcxDecode.c @@ -60,15 +60,25 @@ ImagingPcxDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt } if (state->x >= state->bytes) { - if (state->bytes % state->xsize && state->bytes > state->xsize) { - int bands = state->bytes / state->xsize; - int stride = state->bytes / bands; + int bands; + int xsize = 0; + int stride = 0; + if (state->bits == 2 || state->bits == 4) { + xsize = (state->xsize + 7) / 8; + bands = state->bits; + stride = state->bytes / state->bits; + } else { + xsize = state->xsize; + bands = state->bytes / state->xsize; + if (bands != 0) { + stride = state->bytes / bands; + } + } + if (stride > xsize) { int i; for (i = 1; i < bands; i++) { // note -- skipping first band memmove( - &state->buffer[i * state->xsize], - &state->buffer[i * stride], - state->xsize + &state->buffer[i * xsize], &state->buffer[i * stride], xsize ); } } diff --git a/src/libImaging/Point.c b/src/libImaging/Point.c index 6a4060b4b..b11ea62ed 100644 --- a/src/libImaging/Point.c +++ b/src/libImaging/Point.c @@ -197,8 +197,9 @@ ImagingPoint(Imaging imIn, const char *mode, const void *table) { return imOut; mode_mismatch: - return (Imaging - )ImagingError_ValueError("point operation not supported for this mode"); + return (Imaging)ImagingError_ValueError( + "point operation not supported for this mode" + ); } Imaging diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index f5e386dc2..b114e0023 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -470,7 +470,8 @@ ImagingResampleHorizontal_16bpc( double *k; int bigendian = 0; - if (strcmp(imIn->mode, "I;16N") == 0 + if ( + strcmp(imIn->mode, "I;16N") == 0 #ifdef WORDS_BIGENDIAN || strcmp(imIn->mode, "I;16B") == 0 #endif @@ -509,7 +510,8 @@ ImagingResampleVertical_16bpc( double *k; int bigendian = 0; - if (strcmp(imIn->mode, "I;16N") == 0 + if ( + strcmp(imIn->mode, "I;16N") == 0 #ifdef WORDS_BIGENDIAN || strcmp(imIn->mode, "I;16B") == 0 #endif diff --git a/src/libImaging/Storage.c b/src/libImaging/Storage.c index 4fa4ecd1c..6fe26e1bd 100644 --- a/src/libImaging/Storage.c +++ b/src/libImaging/Storage.c @@ -602,8 +602,9 @@ ImagingBorrowArrow( } if (!borrowed_buffer) { - return (Imaging - )ImagingError_ValueError("Arrow Array, exactly 2 buffers required"); + return (Imaging)ImagingError_ValueError( + "Arrow Array, exactly 2 buffers required" + ); } for (y = i = 0; y < im->ysize; y++) { @@ -644,7 +645,7 @@ ImagingNewInternal(const char *mode, int xsize, int ysize, int dirty) { return im; } - ImagingError_Clear(); + PyErr_Clear(); // Try to allocate the image once more with smallest possible block size MUTEX_LOCK(&ImagingDefaultArena.mutex); @@ -723,6 +724,8 @@ ImagingNewArrow( int64_t pixels = (int64_t)xsize * (int64_t)ysize; // fmt:off // don't reformat this + // stored as a single array, one element per pixel, either single band + // or multiband, where each pixel is an I32. if (((strcmp(schema->format, "I") == 0 // int32 && im->pixelsize == 4 // 4xchar* storage && im->bands >= 2) // INT32 into any INT32 Storage mode @@ -735,6 +738,7 @@ ImagingNewArrow( return im; } } + // Stored as [[r,g,b,a],...] if (strcmp(schema->format, "+w:4") == 0 // 4 up array && im->pixelsize == 4 // storage as 32 bpc && schema->n_children > 0 // make sure schema is well formed. @@ -750,6 +754,17 @@ ImagingNewArrow( return im; } } + // Stored as [r,g,b,a,r,g,b,a,...] + if (strcmp(schema->format, "C") == 0 // uint8 + && im->pixelsize == 4 // storage as 32 bpc + && schema->n_children == 0 // make sure schema is well formed. + && strcmp(im->arrow_band_format, "C") == 0 // expected format + && 4 * pixels == external_array->length) { // expected length + // single flat array, interleaved storage. + if (ImagingBorrowArrow(im, external_array, 1, array_capsule)) { + return im; + } + } // fmt: on ImagingDelete(im); return NULL; diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 9a2db95b4..e289ce405 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -557,7 +557,8 @@ _decodeStrip( (tdata_t)state->buffer, strip_size ) == -1) { - TRACE(("Decode Error, strip %d\n", TIFFComputeStrip(tiff, state->y, 0)) + TRACE( + ("Decode Error, strip %d\n", TIFFComputeStrip(tiff, state->y, 0)) ); state->errcode = IMAGING_CODEC_BROKEN; return -1; @@ -929,6 +930,27 @@ ImagingLibTiffSetField(ImagingCodecState state, ttag_t tag, ...) { return status; } +int +ImagingLibTiffEncodeCleanup(ImagingCodecState state) { + TIFFSTATE *clientstate = (TIFFSTATE *)state->context; + TIFF *tiff = clientstate->tiff; + + if (!tiff) { + return 0; + } + // TIFFClose in libtiff calls tif_closeproc and TIFFCleanup + if (clientstate->fp) { + // Python will manage the closing of the file rather than libtiff + // So only call TIFFCleanup + TIFFCleanup(tiff); + } else { + // When tif_closeproc refers to our custom _tiffCloseProc though, + // that is fine, as it does not close the file + TIFFClose(tiff); + } + return 0; +} + int ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes) { /* One shot encoder. Encode everything to the tiff in the clientstate. @@ -1010,17 +1032,10 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt TRACE(("Encode Error, row %d\n", state->y)); state->errcode = IMAGING_CODEC_BROKEN; - // TIFFClose in libtiff calls tif_closeproc and TIFFCleanup if (clientstate->fp) { - // Python will manage the closing of the file rather than libtiff - // So only call TIFFCleanup TIFFCleanup(tiff); + clientstate->tiff = NULL; } else { - // When tif_closeproc refers to our custom _tiffCloseProc though, - // that is fine, as it does not close the file - TIFFClose(tiff); - } - if (!clientstate->fp) { free(clientstate->data); } return -1; @@ -1036,22 +1051,11 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt TRACE(("Error flushing the tiff")); // likely reason is memory. state->errcode = IMAGING_CODEC_MEMORY; - if (clientstate->fp) { - TIFFCleanup(tiff); - } else { - TIFFClose(tiff); - } if (!clientstate->fp) { free(clientstate->data); } return -1; } - TRACE(("Closing \n")); - if (clientstate->fp) { - TIFFCleanup(tiff); - } else { - TIFFClose(tiff); - } // reset the clientstate metadata to use it to read out the buffer. clientstate->loc = 0; clientstate->size = clientstate->eof; // redundant? diff --git a/src/libImaging/TiffDecode.h b/src/libImaging/TiffDecode.h index 22361210d..77808b543 100644 --- a/src/libImaging/TiffDecode.h +++ b/src/libImaging/TiffDecode.h @@ -40,6 +40,8 @@ ImagingLibTiffInit(ImagingCodecState state, int fp, uint32_t offset); extern int ImagingLibTiffEncodeInit(ImagingCodecState state, char *filename, int fp); extern int +ImagingLibTiffEncodeCleanup(ImagingCodecState state); +extern int ImagingLibTiffMergeFieldInfo( ImagingCodecState state, TIFFDataType field_type, int key, int is_var_length ); diff --git a/src/map.c b/src/map.c index c66702981..9a3144ab9 100644 --- a/src/map.c +++ b/src/map.c @@ -137,6 +137,7 @@ PyImaging_MapBuffer(PyObject *self, PyObject *args) { } } + im->read_only = view.readonly; im->destroy = mapping_destroy_buffer; Py_INCREF(target); diff --git a/tox.ini b/tox.ini index 4065245ee..967d4b537 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 313, 312, 311, 310, 39} + py{py3, 314, 313, 312, 311, 310, 39} [testenv] deps = diff --git a/winbuild/README.md b/winbuild/README.md index c474f12ce..0d3ec8d8a 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -11,8 +11,7 @@ For more extensive info, see the [Windows build instructions](build.rst). * Requires Microsoft Visual Studio 2017 or newer with C++ component. * Requires NASM for libjpeg-turbo, a required dependency when using this script. * Requires CMake 3.15 or newer (available as Visual Studio component). -* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise and Windows Server - 2019 with Visual Studio 2019 Enterprise (GitHub Actions). +* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise (GitHub Actions). Here's an example script to build on Windows: diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 17fc37572..4baa5ccef 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -113,12 +113,12 @@ V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.3", "FRIBIDI": "1.0.16", - "HARFBUZZ": "11.1.0", - "JPEGTURBO": "3.1.0", + "HARFBUZZ": "11.2.1", + "JPEGTURBO": "3.1.1", "LCMS2": "2.17", - "LIBAVIF": "1.2.1", + "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.3.4", - "LIBPNG": "1.6.47", + "LIBPNG": "1.6.49", "LIBWEBP": "1.5.0", "OPENJPEG": "2.5.3", "TIFF": "4.7.0", @@ -385,10 +385,11 @@ DEPS: dict[str, dict[str, Any]] = { "bins": [r"*.dll"], }, "libavif": { - "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['LIBAVIF']}.zip", - "filename": f"libavif-{V['LIBAVIF']}.zip", + "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['LIBAVIF']}.tar.gz", + "filename": f"libavif-{V['LIBAVIF']}.tar.gz", "license": "LICENSE", "build": [ + "rustup update", f"{sys.executable} -m pip install meson", *cmds_cmake( "avif_static", @@ -399,7 +400,6 @@ DEPS: dict[str, dict[str, Any]] = { "-DAVIF_CODEC_DAV1D=LOCAL", "-DAVIF_CODEC_RAV1E=LOCAL", "-DAVIF_CODEC_SVT=LOCAL", - "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", ), cmd_xcopy("include", "{inc_dir}"), ],