diff --git a/.appveyor.yml b/.appveyor.yml index 1cca224ab..20908052b 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -25,8 +25,8 @@ install: - mv c:\pillow-depends-main c:\pillow-depends - xcopy /S /Y c:\pillow-depends\test_images\* c:\pillow\tests\images - 7z x ..\pillow-depends\nasm-2.15.05-win64.zip -oc:\ -- ..\pillow-depends\gs9561w32.exe /S -- path c:\nasm-2.15.05;C:\Program Files (x86)\gs\gs9.56.1\bin;%PATH% +- ..\pillow-depends\gs1000w32.exe /S +- path c:\nasm-2.15.05;C:\Program Files (x86)\gs\gs10.0.0\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | c:\python37\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ diff --git a/.ci/install.sh b/.ci/install.sh index 7ead209be..518b66acc 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -37,8 +37,7 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then - # TODO Remove condition when NumPy supports 3.11 - if ! [ "$GHA_PYTHON_VERSION" == "3.11-dev" ]; then python3 -m pip install numpy ; fi + python3 -m pip install numpy # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..d1d824335 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "labels": [ + "Dependency" + ], + "packageRules": [ + { + "groupName": "github-actions", + "matchManagers": ["github-actions"], + "separateMajorMinor": "false" + } + ], + "schedule": ["on the 3rd day of the month"] +} diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 0e0abaf95..db0307046 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -11,6 +11,13 @@ on: - "**.h" workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: Fuzzing: runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 527f26d35..6195f973b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,10 @@ on: [push, pull_request, workflow_dispatch] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: @@ -16,7 +20,7 @@ jobs: - uses: actions/checkout@v3 - name: pre-commit cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.cache/pre-commit key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} @@ -24,7 +28,7 @@ jobs: lint-pre-commit- - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" cache: pip diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index bb0bcd680..65f2b81d5 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -14,8 +14,7 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma echo -e "[openblas]\nlibraries = openblas\nlibrary_dirs = /usr/local/opt/openblas/lib" >> ~/.numpy-site.cfg -# TODO Remove condition when NumPy supports 3.11 -if ! [ "$GHA_PYTHON_VERSION" == "3.11-dev" ]; then python3 -m pip install numpy ; fi +python3 -m pip install numpy # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 7ee76c4ac..9e2fdc096 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: update_release_draft: permissions: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index cc5e0d488..ffac91cec 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,6 +8,10 @@ on: permissions: issues: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: stale: if: github.repository_owner == 'python-pillow' @@ -16,7 +20,7 @@ jobs: steps: - name: "Check issues" - uses: actions/stale@v5 + uses: actions/stale@v6 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "Awaiting OP Action" diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 417b1f212..5b9ab0eda 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -2,6 +2,13 @@ name: Test Cygwin on: [push, pull_request, workflow_dispatch] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: windows-latest @@ -41,7 +48,7 @@ jobs: qt5-devel-tools subversion xorg-server-extra zlib-devel - name: Add Lapack to PATH - uses: egor-tensin/cleanup-path@v1 + uses: egor-tensin/cleanup-path@v2 with: dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index a78972607..c68d43935 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -5,6 +5,10 @@ on: [push, pull_request, workflow_dispatch] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: @@ -79,7 +83,7 @@ jobs: MATRIX_DOCKER: ${{ matrix.docker }} - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 7ddb71e1f..ccf6e193a 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -5,6 +5,10 @@ on: [push, pull_request, workflow_dispatch] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: windows-latest @@ -73,11 +77,11 @@ jobs: python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests - name: Upload coverage - run: | - python3 -m pip install codecov - bash <(curl -s https://codecov.io/bash) -F GHA_Windows - env: - CODECOV_NAME: ${{ matrix.name }} + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: GHA_Windows + name: ${{ matrix.name }} success: permissions: diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index dda1b3577..219189cf2 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -16,6 +16,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index d8a1f23fe..394b67de2 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -5,13 +5,17 @@ on: [push, pull_request, workflow_dispatch] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: windows-latest strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11-dev"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] architecture: ["x86", "x64"] include: # PyPy 7.3.4+ only ships 64-bit binaries for Windows @@ -36,7 +40,7 @@ jobs: # sets env: pythonLocation - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} @@ -55,8 +59,8 @@ jobs: 7z x winbuild\depends\nasm-2.15.05-win64.zip "-o$env:RUNNER_WORKSPACE\" echo "$env:RUNNER_WORKSPACE\nasm-2.15.05" >> $env:GITHUB_PATH - winbuild\depends\gs9561w32.exe /S - echo "C:\Program Files (x86)\gs\gs9.56.1\bin" >> $env:GITHUB_PATH + winbuild\depends\gs1000w32.exe /S + echo "C:\Program Files (x86)\gs\gs10.0.0\bin" >> $env:GITHUB_PATH xcopy /S /Y winbuild\depends\test_images\* Tests\images\ @@ -66,7 +70,7 @@ jobs: - name: Cache build id: build-cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: winbuild\build key: @@ -86,19 +90,28 @@ jobs: if: steps.build-cache.outputs.cache-hit != 'true' run: "& winbuild\\build\\build_dep_zlib.cmd" - - name: Build dependencies / LibTiff + - name: Build dependencies / xz if: steps.build-cache.outputs.cache-hit != 'true' - run: "& winbuild\\build\\build_dep_libtiff.cmd" + run: "& winbuild\\build\\build_dep_xz.cmd" - name: Build dependencies / WebP if: steps.build-cache.outputs.cache-hit != 'true' run: "& winbuild\\build\\build_dep_libwebp.cmd" + - name: Build dependencies / LibTiff + if: steps.build-cache.outputs.cache-hit != 'true' + run: "& winbuild\\build\\build_dep_libtiff.cmd" + # for FreeType CBDT/SBIX font support - name: Build dependencies / libpng if: steps.build-cache.outputs.cache-hit != 'true' run: "& winbuild\\build\\build_dep_libpng.cmd" + # for FreeType WOFF2 font support + - name: Build dependencies / brotli + if: steps.build-cache.outputs.cache-hit != 'true' + run: "& winbuild\\build\\build_dep_brotli.cmd" + - name: Build dependencies / FreeType if: steps.build-cache.outputs.cache-hit != 'true' run: "& winbuild\\build\\build_dep_freetype.cmd" @@ -171,7 +184,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: file: ./coverage.xml flags: GHA_Windows @@ -183,6 +196,22 @@ jobs: run: | mkdir fribidi\${{ matrix.architecture }} copy winbuild\build\bin\fribidi* fribidi\${{ matrix.architecture }} + setlocal EnableDelayedExpansion + for %%f in (winbuild\build\license\*) do ( + set x=%%~nf + rem Skip FriBiDi license, it is not included in the wheel. + set fribidi=!x:~0,7! + if NOT !fribidi!==fribidi ( + rem Skip imagequant license, it is not included in the wheel. + set libimagequant=!x:~0,13! + if NOT !libimagequant!==libimagequant ( + echo. >> LICENSE + echo ===== %%~nf ===== >> LICENSE + echo. >> LICENSE + type %%f >> LICENSE + ) + ) + ) for /f "tokens=3 delims=/" %%a in ("${{ github.ref }}") do echo ::set-output name=dist::dist-%%a winbuild\\build\\build_pillow.cmd --disable-imagequant bdist_wheel shell: cmd diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5614ad5f2..645384c02 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,10 @@ on: [push, pull_request, workflow_dispatch] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: @@ -18,7 +22,7 @@ jobs: python-version: [ "pypy-3.8", "pypy-3.7", - "3.11-dev", + "3.11", "3.10", "3.9", "3.8", @@ -30,11 +34,6 @@ jobs: REVERSE: "--reverse" - python-version: "3.8" PYTHONOPTIMIZE: 2 - # Include new variables for Codecov - - os: ubuntu-latest - codecov-flag: GHA_Ubuntu - - os: macos-latest - codecov-flag: GHA_macOS runs-on: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} @@ -43,7 +42,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip @@ -99,7 +98,6 @@ jobs: - name: Docs if: startsWith(matrix.os, 'ubuntu') && matrix.python-version == 3.10 run: | - python3 -m pip install furo sphinx-copybutton sphinx-issues sphinx-removed-in sphinxext-opengraph make doccheck - name: After success @@ -107,9 +105,11 @@ jobs: .ci/after_success.sh - name: Upload coverage - run: bash <(curl -s https://codecov.io/bash) -F ${{ matrix.codecov-flag }} - env: - CODECOV_NAME: ${{ matrix.os }} Python ${{ matrix.python-version }} + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: ${{ matrix.os == 'macos-latest' && 'GHA_macOS' || 'GHA_Ubuntu' }} + name: ${{ matrix.os }} Python ${{ matrix.python-version }} success: permissions: diff --git a/.github/workflows/tidelift.yml b/.github/workflows/tidelift.yml index c73f25431..69f9e5476 100644 --- a/.github/workflows/tidelift.yml +++ b/.github/workflows/tidelift.yml @@ -1,4 +1,5 @@ name: Tidelift Align + on: schedule: - cron: "30 2 * * *" # daily at 02:30 UTC @@ -15,6 +16,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: if: github.repository_owner == 'python-pillow' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1bb71bd72..f81bcb956 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 22.8.0 hooks: - id: black args: ["--target-version", "py37"] @@ -14,18 +14,18 @@ repos: - id: isort - repo: https://github.com/asottile/yesqa - rev: v1.3.0 + rev: v1.4.0 hooks: - id: yesqa - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.3.0 + rev: v1.3.1 hooks: - id: remove-tabs exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.opt$) - repo: https://github.com/PyCQA/flake8 - rev: 5.0.2 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -40,6 +40,7 @@ repos: rev: v4.3.0 hooks: - id: check-merge-conflict + - id: check-json - id: check-yaml - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/CHANGES.rst b/CHANGES.rst index fb634eaba..74ff24f5c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,126 @@ Changelog (Pillow) 9.3.0 (unreleased) ------------------ +- Fixed set_variation_by_name offset #6445 + [radarhere] + +- Fix malloc in _imagingft.c:font_setvaraxes #6690 + [cgohlke] + +- Release Python GIL when converting images using matrix operations #6418 + [hmaarrfk] + +- Added ExifTags enums #6630 + [radarhere] + +- Do not modify previous frame when calculating delta in PNG #6683 + [radarhere] + +- Added support for reading BMP images with RLE4 compression #6674 + [npjg, radarhere] + +- Decode JPEG compressed BLP1 data in original mode #6678 + [radarhere] + +- Added GPS TIFF tag info #6661 + [radarhere] + +- Added conversion between RGB/RGBA/RGBX and LAB #6647 + [radarhere] + +- Do not attempt normalization if mode is already normal #6644 + [radarhere] + +- Fixed seeking to an L frame in a GIF #6576 + [radarhere] + +- Consider all frames when selecting mode for PNG save_all #6610 + [radarhere] + +- Don't reassign crc on ChunkStream close #6627 + [wiredfool, radarhere] + +- Raise a warning if NumPy failed to raise an error during conversion #6594 + [radarhere] + +- Show all frames in ImageShow #6611 + [radarhere] + +- Allow FLI palette chunk to not be first #6626 + [radarhere] + +- If first GIF frame has transparency for RGB_ALWAYS loading strategy, use RGBA mode #6592 + [radarhere] + +- Round box position to integer when pasting embedded color #6517 + [radarhere, nulano] + +- Removed EXIF prefix when saving WebP #6582 + [radarhere] + +- Pad IM palette to 768 bytes when saving #6579 + [radarhere] + +- Added DDS BC6H reading #6449 + [ShadelessFox, REDxEYE, radarhere] + +- Added support for opening WhiteIsZero 16-bit integer TIFF images #6642 + [JayWiz, radarhere] + +- Raise an error when allocating translucent color to RGB palette #6654 + [jsbueno, radarhere] + +- Added reading of TIFF child images #6569 + [radarhere] + +- Improved ImageOps palette handling #6596 + [PososikTeam, radarhere] + +- Defer parsing of palette into colors #6567 + [radarhere] + +- Apply transparency to P images in ImageTk.PhotoImage #6559 + [radarhere] + +- Use rounding in ImageOps contain() and pad() #6522 + [bibinhashley, radarhere] + +- Fixed GIF remapping to palette with duplicate entries #6548 + [radarhere] + +- Allow remap_palette() to return an image with less than 256 palette entries #6543 + [radarhere] + +- Corrected BMP and TGA palette size when saving #6500 + [radarhere] + +- Do not call load() before draft() in Image.thumbnail #6539 + [radarhere] + +- Copy palette when converting from P to PA #6497 + [radarhere] + +- Allow RGB and RGBA values for PA image putpixel #6504 + [radarhere] + +- Removed support for tkinter in PyPy before Python 3.6 #6551 + [nulano] + +- Do not use CCITTFaxDecode filter if libtiff is not available #6518 + [radarhere] + +- Fallback to not using mmap if buffer is not large enough #6510 + [radarhere] + +- Fixed writing bytes as ASCII tag #6493 + [radarhere] + +- Open 1 bit EPS in mode 1 #6499 + [radarhere] + +- Removed support for tkinter before Python 1.5.2 #6549 + [radarhere] + - Allow default ImageDraw font to be set #6484 [radarhere, hugovk] diff --git a/MANIFEST.in b/MANIFEST.in index 26f9401f2..08f6dfc08 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -25,6 +25,7 @@ exclude .coveragerc exclude .editorconfig exclude .readthedocs.yml exclude codecov.yml +exclude renovate.json global-exclude .git* global-exclude *.pyc global-exclude *.so diff --git a/Makefile b/Makefile index 219dda1de..8f2862948 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,12 @@ coverage: .PHONY: doc doc: + python3 -c "import PIL" > /dev/null 2>&1 || python3 -m pip install . $(MAKE) -C docs html .PHONY: doccheck doccheck: - $(MAKE) -C docs html + $(MAKE) doc # Don't make our tests rely on the links in the docs being up every single build. # We don't control them. But do check, and update them to the target of their redirects. $(MAKE) -C docs linkcheck || true diff --git a/README.md b/README.md index 5e9adaf7e..e7c0ebc5a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ As of 2019, Pillow development is Number of PyPI downloads + OpenSSF Best Practices diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index 104ff677c..da559b3d3 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -8,6 +8,7 @@ TINY5x3GX.ttf, from http://velvetyne.fr/fonts/tiny ArefRuqaa-Regular.ttf, from https://github.com/google/fonts/tree/master/ofl/arefruqaa ter-x20b.pcf, from http://terminus-font.sourceforge.net/ BungeeColor-Regular_colr_Windows.ttf, from https://github.com/djrrb/bungee +OpenSans.woff2, from https://fonts.googleapis.com/css?family=Open+Sans All of the above fonts are published under the SIL Open Font License (OFL) v1.1 (http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL), which allows you to copy, modify, and redistribute them if you need to. diff --git a/Tests/fonts/OpenSans.woff2 b/Tests/fonts/OpenSans.woff2 new file mode 100644 index 000000000..15339ea9c Binary files /dev/null and b/Tests/fonts/OpenSans.woff2 differ diff --git a/Tests/helper.py b/Tests/helper.py index 13c6955e4..0d1d03ac8 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -208,12 +208,11 @@ class PillowLeakTestCase: # ru_maxrss # This is the maximum resident set size utilized (in bytes). return mem / 1024 # Kb - else: - # linux - # man 2 getrusage - # ru_maxrss (since Linux 2.6.32) - # This is the maximum resident set size used (in kilobytes). - return mem # Kb + # linux + # man 2 getrusage + # ru_maxrss (since Linux 2.6.32) + # This is the maximum resident set size used (in kilobytes). + return mem # Kb def _test_leak(self, core): start_mem = self._get_mem_usage() @@ -285,7 +284,7 @@ def magick_command(): if imagemagick and shutil.which(imagemagick[0]): return imagemagick - elif graphicsmagick and shutil.which(graphicsmagick[0]): + if graphicsmagick and shutil.which(graphicsmagick[0]): return graphicsmagick diff --git a/Tests/images/1.eps b/Tests/images/1.eps new file mode 100644 index 000000000..727dc9b7f Binary files /dev/null and b/Tests/images/1.eps differ diff --git a/Tests/images/bc6h.dds b/Tests/images/bc6h.dds new file mode 100644 index 000000000..77993a0c1 Binary files /dev/null and b/Tests/images/bc6h.dds differ diff --git a/Tests/images/bc6h.png b/Tests/images/bc6h.png new file mode 100644 index 000000000..609f11489 Binary files /dev/null and b/Tests/images/bc6h.png differ diff --git a/Tests/images/bc6h_sf.dds b/Tests/images/bc6h_sf.dds new file mode 100644 index 000000000..2ab1b195b Binary files /dev/null and b/Tests/images/bc6h_sf.dds differ diff --git a/Tests/images/bc6h_sf.png b/Tests/images/bc6h_sf.png new file mode 100644 index 000000000..6a3b73d5f Binary files /dev/null and b/Tests/images/bc6h_sf.png differ diff --git a/Tests/images/blp/blp1_jpeg2.blp b/Tests/images/blp/blp1_jpeg2.blp new file mode 100644 index 000000000..890180e9b Binary files /dev/null and b/Tests/images/blp/blp1_jpeg2.blp differ diff --git a/Tests/images/bw_gradient.imt b/Tests/images/bw_gradient.imt new file mode 100644 index 000000000..d765cf95f Binary files /dev/null and b/Tests/images/bw_gradient.imt differ diff --git a/Tests/images/child_ifd.tiff b/Tests/images/child_ifd.tiff new file mode 100644 index 000000000..700185d88 Binary files /dev/null and b/Tests/images/child_ifd.tiff differ diff --git a/Tests/images/child_ifd_jpeg.tiff b/Tests/images/child_ifd_jpeg.tiff new file mode 100644 index 000000000..f5e3d129d Binary files /dev/null and b/Tests/images/child_ifd_jpeg.tiff differ diff --git a/Tests/images/hopper_lzma.tif b/Tests/images/hopper_lzma.tif new file mode 100644 index 000000000..d7ca089fc Binary files /dev/null and b/Tests/images/hopper_lzma.tif differ diff --git a/Tests/images/hopper_palette_chunk_second.fli b/Tests/images/hopper_palette_chunk_second.fli new file mode 100644 index 000000000..54447de0a Binary files /dev/null and b/Tests/images/hopper_palette_chunk_second.fli differ diff --git a/Tests/images/hopper_webp.png b/Tests/images/hopper_webp.png new file mode 100644 index 000000000..94b927ac2 Binary files /dev/null and b/Tests/images/hopper_webp.png differ diff --git a/Tests/images/hopper_webp.tif b/Tests/images/hopper_webp.tif new file mode 100644 index 000000000..5e398606c Binary files /dev/null and b/Tests/images/hopper_webp.tif differ diff --git a/Tests/images/mmap_error.bmp b/Tests/images/mmap_error.bmp new file mode 100644 index 000000000..04df163d7 Binary files /dev/null and b/Tests/images/mmap_error.bmp differ diff --git a/Tests/images/no_palette_after_rgb.gif b/Tests/images/no_palette_after_rgb.gif new file mode 100644 index 000000000..8704c464c Binary files /dev/null and b/Tests/images/no_palette_after_rgb.gif differ diff --git a/Tests/images/palette_not_needed_for_second_frame.gif b/Tests/images/palette_not_needed_for_second_frame.gif new file mode 100644 index 000000000..0617291d1 Binary files /dev/null and b/Tests/images/palette_not_needed_for_second_frame.gif differ diff --git a/Tests/images/test_woff2.png b/Tests/images/test_woff2.png new file mode 100644 index 000000000..4eb3be4c7 Binary files /dev/null and b/Tests/images/test_woff2.png differ diff --git a/Tests/images/text_float_coord.png b/Tests/images/text_float_coord.png new file mode 100644 index 000000000..49468698c Binary files /dev/null and b/Tests/images/text_float_coord.png differ diff --git a/Tests/images/text_float_coord_1_alt.png b/Tests/images/text_float_coord_1_alt.png new file mode 100644 index 000000000..50bdac3d8 Binary files /dev/null and b/Tests/images/text_float_coord_1_alt.png differ diff --git a/Tests/images/unimplemented_dxgi_format.dds b/Tests/images/unimplemented_dxgi_format.dds index 5ecb42006..70860f2fc 100644 Binary files a/Tests/images/unimplemented_dxgi_format.dds and b/Tests/images/unimplemented_dxgi_format.dds differ diff --git a/Tests/images/variation_adobe_name.png b/Tests/images/variation_adobe_name.png index 11ceaf6e6..5168e04b9 100644 Binary files a/Tests/images/variation_adobe_name.png and b/Tests/images/variation_adobe_name.png differ diff --git a/Tests/images/variation_adobe_older_harfbuzz_name.png b/Tests/images/variation_adobe_older_harfbuzz_name.png index 2adb517a7..fa0e307b4 100644 Binary files a/Tests/images/variation_adobe_older_harfbuzz_name.png and b/Tests/images/variation_adobe_older_harfbuzz_name.png differ diff --git a/Tests/test_000_sanity.py b/Tests/test_000_sanity.py index 59fbac527..3fd982474 100644 --- a/Tests/test_000_sanity.py +++ b/Tests/test_000_sanity.py @@ -1,19 +1,18 @@ -import PIL -import PIL.Image +from PIL import Image def test_sanity(): # Make sure we have the binary extension - PIL.Image.core.new("L", (100, 100)) + Image.core.new("L", (100, 100)) # Create an image and do stuff with it. - im = PIL.Image.new("1", (100, 100)) + im = Image.new("1", (100, 100)) assert (im.mode, im.size) == ("1", (100, 100)) assert len(im.tobytes()) == 1300 # Create images in all remaining major modes. - PIL.Image.new("L", (100, 100)) - PIL.Image.new("P", (100, 100)) - PIL.Image.new("RGB", (100, 100)) - PIL.Image.new("I", (100, 100)) - PIL.Image.new("F", (100, 100)) + Image.new("L", (100, 100)) + Image.new("P", (100, 100)) + Image.new("RGB", (100, 100)) + Image.new("I", (100, 100)) + Image.new("F", (100, 100)) diff --git a/Tests/test_features.py b/Tests/test_features.py index 284f72205..c4e9cd368 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -70,14 +70,14 @@ def test_libimagequant_version(): assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) -def test_check_modules(): - for feature in features.modules: - assert features.check_module(feature) in [True, False] +@pytest.mark.parametrize("feature", features.modules) +def test_check_modules(feature): + assert features.check_module(feature) in [True, False] -def test_check_codecs(): - for feature in features.codecs: - assert features.check_codec(feature) in [True, False] +@pytest.mark.parametrize("feature", features.codecs) +def test_check_codecs(feature): + assert features.check_codec(feature) in [True, False] def test_check_warns_on_nonexistent(): diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 0ff05f608..51637c786 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -39,13 +39,12 @@ def test_apng_basic(): assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_fdat(): - with Image.open("Tests/images/apng/split_fdat.png") as im: - im.seek(im.n_frames - 1) - assert im.getpixel((0, 0)) == (0, 255, 0, 255) - assert im.getpixel((64, 32)) == (0, 255, 0, 255) - - with Image.open("Tests/images/apng/split_fdat_zero_chunk.png") as im: +@pytest.mark.parametrize( + "filename", + ("Tests/images/apng/split_fdat.png", "Tests/images/apng/split_fdat_zero_chunk.png"), +) +def test_apng_fdat(filename): + with Image.open(filename) as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -554,18 +553,20 @@ def test_apng_save_disposal(tmp_path): def test_apng_save_disposal_previous(tmp_path): test_file = str(tmp_path / "temp.png") size = (128, 64) - transparent = Image.new("RGBA", size, (0, 0, 0, 0)) + blue = Image.new("RGBA", size, (0, 0, 255, 255)) red = Image.new("RGBA", size, (255, 0, 0, 255)) green = Image.new("RGBA", size, (0, 255, 0, 255)) # test OP_NONE - transparent.save( + blue.save( test_file, save_all=True, append_images=[red, green], disposal=PngImagePlugin.Disposal.OP_PREVIOUS, ) with Image.open(test_file) as im: + assert im.getpixel((0, 0)) == (0, 0, 255, 255) + im.seek(2) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -648,6 +649,16 @@ def test_seek_after_close(): im.seek(0) +@pytest.mark.parametrize("mode", ("RGBA", "RGB", "P")) +def test_different_modes_in_later_frames(mode, tmp_path): + test_file = str(tmp_path / "temp.png") + + im = Image.new("L", (1, 1)) + im.save(test_file, save_all=True, append_images=[Image.new(mode, (1, 1))]) + with Image.open(test_file) as reloaded: + assert reloaded.mode == mode + + def test_constants_deprecation(): for enum, prefix in { PngImagePlugin.Disposal: "APNG_DISPOSE_", diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index c1fae44ca..ba2781820 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -14,6 +14,9 @@ def test_load_blp1(): with Image.open("Tests/images/blp/blp1_jpeg.blp") as im: assert_image_equal_tofile(im, "Tests/images/blp/blp1_jpeg.png") + with Image.open("Tests/images/blp/blp1_jpeg2.blp") as im: + im.load() + def test_load_blp2_raw(): with Image.open("Tests/images/blp/blp2_raw.blp") as im: diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index d58666b44..5f6d52355 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -39,6 +39,13 @@ def test_invalid_file(): BmpImagePlugin.BmpImageFile(fp) +def test_fallback_if_mmap_errors(): + # This image has been truncated, + # so that the buffer is not large enough when using mmap + with Image.open("Tests/images/mmap_error.bmp") as im: + assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp") + + def test_save_to_bytes(): output = io.BytesIO() im = hopper() @@ -51,6 +58,18 @@ def test_save_to_bytes(): assert reloaded.format == "BMP" +def test_small_palette(tmp_path): + im = Image.new("P", (1, 1)) + colors = [0, 0, 0, 125, 125, 125, 255, 255, 255] + im.putpalette(colors) + + out = str(tmp_path / "temp.bmp") + im.save(out) + + with Image.open(out) as reloaded: + assert reloaded.getpalette() == colors + + def test_save_too_large(tmp_path): outfile = str(tmp_path / "temp.bmp") with Image.new("RGB", (1, 1)) as im: @@ -157,6 +176,11 @@ def test_rle8(): im.load() +def test_rle4(): + with Image.open("Tests/images/bmp/g/pal4rle.bmp") as im: + assert_image_similar_tofile(im, "Tests/images/bmp/g/pal4.bmp", 12) + + @pytest.mark.parametrize( "file_name,length", ( diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 351001199..4b9f8949e 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -16,6 +16,8 @@ TEST_FILE_DX10_BC5_TYPELESS = "Tests/images/bc5_typeless.dds" TEST_FILE_DX10_BC5_UNORM = "Tests/images/bc5_unorm.dds" TEST_FILE_DX10_BC5_SNORM = "Tests/images/bc5_snorm.dds" TEST_FILE_BC5S = "Tests/images/bc5s.dds" +TEST_FILE_BC6H = "Tests/images/bc6h.dds" +TEST_FILE_BC6HS = "Tests/images/bc6h_sf.dds" TEST_FILE_DX10_BC7 = "Tests/images/bc7-argb-8bpp_MipMaps-1.dds" TEST_FILE_DX10_BC7_UNORM_SRGB = "Tests/images/DXGI_FORMAT_BC7_UNORM_SRGB.dds" TEST_FILE_DX10_R8G8B8A8 = "Tests/images/argb-32bpp_MipMaps-1.dds" @@ -114,6 +116,20 @@ def test_dx10_bc5(image_path, expected_path): assert_image_equal_tofile(im, expected_path.replace(".dds", ".png")) +@pytest.mark.parametrize("image_path", (TEST_FILE_BC6H, TEST_FILE_BC6HS)) +def test_dx10_bc6h(image_path): + """Check DX10 BC6H/BC6HS images can be opened""" + + with Image.open(image_path) as im: + im.load() + + assert im.format == "DDS" + assert im.mode == "RGB" + assert im.size == (128, 128) + + assert_image_equal_tofile(im, image_path.replace(".dds", ".png")) + + def test_dx10_bc7(): """Check DX10 images can be opened""" diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 1790f4f77..015dda992 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -124,14 +124,6 @@ def test_file_object(tmp_path): image1.save(fh, "EPS") -@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_iobase_object(tmp_path): - # issue 479 - with Image.open(FILE1) as image1: - with open(str(tmp_path / "temp_iobase.eps"), "wb") as fh: - image1.save(fh, "EPS") - - @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_bytesio_object(): with open(FILE1, "rb") as f: @@ -146,6 +138,11 @@ def test_bytesio_object(): assert_image_similar(img, image1_scale1_compare, 5) +def test_1_mode(): + with Image.open("Tests/images/1.eps") as im: + assert im.mode == "1" + + def test_image_mode_not_supported(tmp_path): im = hopper("RGBA") tmpfile = str(tmp_path / "temp.eps") @@ -198,25 +195,23 @@ def test_render_scale2(): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_resize(): - files = [FILE1, FILE2, "Tests/images/illu10_preview.eps"] - for fn in files: - with Image.open(fn) as im: - new_size = (100, 100) - im = im.resize(new_size) - assert im.size == new_size +@pytest.mark.parametrize("filename", (FILE1, FILE2, "Tests/images/illu10_preview.eps")) +def test_resize(filename): + with Image.open(filename) as im: + new_size = (100, 100) + im = im.resize(new_size) + assert im.size == new_size @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_thumbnail(): +@pytest.mark.parametrize("filename", (FILE1, FILE2)) +def test_thumbnail(filename): # Issue #619 # Arrange - files = [FILE1, FILE2] - for fn in files: - with Image.open(FILE1) as im: - new_size = (100, 100) - im.thumbnail(new_size) - assert max(im.size) == max(new_size) + with Image.open(filename) as im: + new_size = (100, 100) + im.thumbnail(new_size) + assert max(im.size) == max(new_size) def test_read_binary_preview(): @@ -261,20 +256,19 @@ def test_readline(tmp_path): _test_readline_file_psfile(s, ending) -def test_open_eps(): - # https://github.com/python-pillow/Pillow/issues/1104 - # Arrange - FILES = [ +@pytest.mark.parametrize( + "filename", + ( "Tests/images/illu10_no_preview.eps", "Tests/images/illu10_preview.eps", "Tests/images/illuCS6_no_preview.eps", "Tests/images/illuCS6_preview.eps", - ] - - # Act / Assert - for filename in FILES: - with Image.open(filename) as img: - assert img.mode == "RGB" + ), +) +def test_open_eps(filename): + # https://github.com/python-pillow/Pillow/issues/1104 + with Image.open(filename) as img: + assert img.mode == "RGB" @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index a7d43d2e9..b8b999d70 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -4,7 +4,7 @@ import pytest from PIL import FliImagePlugin, Image -from .helper import assert_image_equal_tofile, is_pypy +from .helper import assert_image_equal, assert_image_equal_tofile, is_pypy # created as an export of a palette image from Gimp2.6 # save as...-> hopper.fli, default options. @@ -79,6 +79,12 @@ def test_invalid_file(): FliImagePlugin.FliImageFile(invalid_file) +def test_palette_chunk_second(): + with Image.open("Tests/images/hopper_palette_chunk_second.fli") as im: + with Image.open(static_test_file) as expected: + assert_image_equal(im.convert("RGB"), expected.convert("RGB")) + + def test_n_frames(): with Image.open(static_test_file) as im: assert im.n_frames == 1 diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 68cb8a36e..926f5c1ee 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -83,18 +83,40 @@ def test_l_mode_transparency(): assert im.load()[0, 0] == 128 +def test_l_mode_after_rgb(): + with Image.open("Tests/images/no_palette_after_rgb.gif") as im: + im.seek(1) + assert im.mode == "RGB" + + im.seek(2) + assert im.mode == "RGB" + + +def test_palette_not_needed_for_second_frame(): + with Image.open("Tests/images/palette_not_needed_for_second_frame.gif") as im: + im.seek(1) + assert_image_similar(im, hopper("L").convert("RGB"), 8) + + def test_strategy(): + with Image.open("Tests/images/iss634.gif") as im: + expected_rgb_always = im.convert("RGB") + with Image.open("Tests/images/chi.gif") as im: - expected_zero = im.convert("RGB") + expected_rgb_always_rgba = im.convert("RGBA") im.seek(1) - expected_one = im.convert("RGB") + expected_different = im.convert("RGB") try: GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS - with Image.open("Tests/images/chi.gif") as im: + with Image.open("Tests/images/iss634.gif") as im: assert im.mode == "RGB" - assert_image_equal(im, expected_zero) + assert_image_equal(im, expected_rgb_always) + + with Image.open("Tests/images/chi.gif") as im: + assert im.mode == "RGBA" + assert_image_equal(im, expected_rgb_always_rgba) GifImagePlugin.LOADING_STRATEGY = ( GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY @@ -105,7 +127,7 @@ def test_strategy(): im.seek(1) assert im.mode == "P" - assert_image_equal(im.convert("RGB"), expected_one) + assert_image_equal(im.convert("RGB"), expected_different) # Change to RGB mode when a frame has an individual palette with Image.open("Tests/images/iss634.gif") as im: @@ -793,24 +815,24 @@ def test_identical_frames(tmp_path): assert reread.info["duration"] == 4500 -def test_identical_frames_to_single_frame(tmp_path): - for duration in ([1000, 1500, 2000, 4000], (1000, 1500, 2000, 4000), 8500): - out = str(tmp_path / "temp.gif") - im_list = [ - Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#000"), - Image.new("L", (100, 100), "#000"), - ] +@pytest.mark.parametrize( + "duration", ([1000, 1500, 2000, 4000], (1000, 1500, 2000, 4000), 8500) +) +def test_identical_frames_to_single_frame(duration, tmp_path): + out = str(tmp_path / "temp.gif") + im_list = [ + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#000"), + Image.new("L", (100, 100), "#000"), + ] - im_list[0].save( - out, save_all=True, append_images=im_list[1:], duration=duration - ) - with Image.open(out) as reread: - # Assert that all frames were combined - assert reread.n_frames == 1 + im_list[0].save(out, save_all=True, append_images=im_list[1:], duration=duration) + with Image.open(out) as reread: + # Assert that all frames were combined + assert reread.n_frames == 1 - # Assert that the new duration is the total of the identical frames - assert reread.info["duration"] == 8500 + # Assert that the new duration is the total of the identical frames + assert reread.info["duration"] == 8500 def test_number_of_loops(tmp_path): @@ -1087,6 +1109,19 @@ def test_palette_save_P(tmp_path): assert_image_equal(reloaded, im) +def test_palette_save_duplicate_entries(tmp_path): + im = Image.new("P", (1, 2)) + im.putpixel((0, 1), 1) + + im.putpalette((0, 0, 0, 0, 0, 0)) + + out = str(tmp_path / "temp.gif") + im.save(out, palette=[0, 0, 0, 0, 0, 0, 1, 1, 1]) + + with Image.open(out) as reloaded: + assert reloaded.convert("RGB").getpixel((0, 1)) == (0, 0, 0) + + def test_palette_save_all_P(tmp_path): frames = [] colors = ((255, 0, 0), (0, 255, 0)) diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index e458a197c..5cf93713b 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -86,6 +86,18 @@ def test_roundtrip(mode, tmp_path): assert_image_equal_tofile(im, out) +def test_small_palette(tmp_path): + im = Image.new("P", (1, 1)) + colors = [0, 1, 2] + im.putpalette(colors) + + out = str(tmp_path / "temp.im") + im.save(out) + + with Image.open(out) as reloaded: + assert reloaded.getpalette() == colors + [0] * 765 + + def test_save_unsupported_mode(tmp_path): out = str(tmp_path / "temp.im") im = hopper("HSV") diff --git a/Tests/test_file_imt.py b/Tests/test_file_imt.py new file mode 100644 index 000000000..f56acc429 --- /dev/null +++ b/Tests/test_file_imt.py @@ -0,0 +1,19 @@ +import io + +import pytest + +from PIL import Image, ImtImagePlugin + +from .helper import assert_image_equal_tofile + + +def test_sanity(): + with Image.open("Tests/images/bw_gradient.imt") as im: + assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") + + +@pytest.mark.parametrize("data", (b"\n", b"\n-", b"width 1\n")) +def test_invalid_file(data): + with io.BytesIO(data) as fp: + with pytest.raises(SyntaxError): + ImtImagePlugin.ImtImageFile(fp) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 12edd7582..fa96e425b 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -30,7 +30,7 @@ from .helper import ( ) try: - import defusedxml.ElementTree as ElementTree + from defusedxml import ElementTree except ImportError: ElementTree = None @@ -150,27 +150,30 @@ class TestFileJpeg: assert not im1.info.get("icc_profile") assert im2.info.get("icc_profile") - def test_icc_big(self): + @pytest.mark.parametrize( + "n", + ( + 0, + 1, + 3, + 4, + 5, + 65533 - 14, # full JPEG marker block + 65533 - 14 + 1, # full block plus one byte + ImageFile.MAXBLOCK, # full buffer block + ImageFile.MAXBLOCK + 1, # full buffer block plus one byte + ImageFile.MAXBLOCK * 4 + 3, # large block + ), + ) + def test_icc_big(self, n): # Make sure that the "extra" support handles large blocks - def test(n): - # The ICC APP marker can store 65519 bytes per marker, so - # using a 4-byte test code should allow us to detect out of - # order issues. - icc_profile = (b"Test" * int(n / 4 + 1))[:n] - assert len(icc_profile) == n # sanity - im1 = self.roundtrip(hopper(), icc_profile=icc_profile) - assert im1.info.get("icc_profile") == (icc_profile or None) - - test(0) - test(1) - test(3) - test(4) - test(5) - test(65533 - 14) # full JPEG marker block - test(65533 - 14 + 1) # full block plus one byte - test(ImageFile.MAXBLOCK) # full buffer block - test(ImageFile.MAXBLOCK + 1) # full buffer block plus one byte - test(ImageFile.MAXBLOCK * 4 + 3) # large block + # The ICC APP marker can store 65519 bytes per marker, so + # using a 4-byte test code should allow us to detect out of + # order issues. + icc_profile = (b"Test" * int(n / 4 + 1))[:n] + assert len(icc_profile) == n # sanity + im1 = self.roundtrip(hopper(), icc_profile=icc_profile) + assert im1.info.get("icc_profile") == (icc_profile or None) @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" @@ -649,19 +652,19 @@ class TestFileJpeg: # Assert assert im.format == "JPEG" - def test_save_correct_modes(self): + @pytest.mark.parametrize("mode", ("1", "L", "RGB", "RGBX", "CMYK", "YCbCr")) + def test_save_correct_modes(self, mode): out = BytesIO() - for mode in ["1", "L", "RGB", "RGBX", "CMYK", "YCbCr"]: - img = Image.new(mode, (20, 20)) - img.save(out, "JPEG") + img = Image.new(mode, (20, 20)) + img.save(out, "JPEG") - def test_save_wrong_modes(self): + @pytest.mark.parametrize("mode", ("LA", "La", "RGBA", "RGBa", "P")) + def test_save_wrong_modes(self, mode): # ref https://github.com/python-pillow/Pillow/issues/2005 out = BytesIO() - for mode in ["LA", "La", "RGBA", "RGBa", "P"]: - img = Image.new(mode, (20, 20)) - with pytest.raises(OSError): - img.save(out, "JPEG") + img = Image.new(mode, (20, 20)) + with pytest.raises(OSError): + img.save(out, "JPEG") def test_save_tiff_with_dpi(self, tmp_path): # Arrange diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 7942d6b9a..cd142e67f 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -126,14 +126,14 @@ def test_prog_res_rt(): assert_image_equal(im, test_card) -def test_default_num_resolutions(): - for num_resolutions in range(2, 6): - d = 1 << (num_resolutions - 1) - im = test_card.resize((d - 1, d - 1)) - with pytest.raises(OSError): - roundtrip(im, num_resolutions=num_resolutions) - reloaded = roundtrip(im) - assert_image_equal(im, reloaded) +@pytest.mark.parametrize("num_resolutions", range(2, 6)) +def test_default_num_resolutions(num_resolutions): + d = 1 << (num_resolutions - 1) + im = test_card.resize((d - 1, d - 1)) + with pytest.raises(OSError): + roundtrip(im, num_resolutions=num_resolutions) + reloaded = roundtrip(im) + assert_image_equal(im, reloaded) def test_reduce(): @@ -266,14 +266,11 @@ def test_rgba(): assert jp2.mode == "RGBA" -def test_16bit_monochrome_has_correct_mode(): - with Image.open("Tests/images/16bit.cropped.j2k") as j2k: - j2k.load() - assert j2k.mode == "I;16" - - with Image.open("Tests/images/16bit.cropped.jp2") as jp2: - jp2.load() - assert jp2.mode == "I;16" +@pytest.mark.parametrize("ext", (".j2k", ".jp2")) +def test_16bit_monochrome_has_correct_mode(ext): + with Image.open("Tests/images/16bit.cropped" + ext) as im: + im.load() + assert im.mode == "I;16" def test_16bit_monochrome_jp2_like_tiff(): diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 86a0fda04..1109cd15e 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -3,6 +3,7 @@ import io import itertools import os import re +import sys from collections import namedtuple import pytest @@ -509,20 +510,13 @@ class TestFileLibTiff(LibTiffTestCase): # colormap/palette tag assert len(reloaded.tag_v2[320]) == 768 - def xtest_bw_compression_w_rgb(self, tmp_path): - """This test passes, but when running all tests causes a failure due - to output on stderr from the error thrown by libtiff. We need to - capture that but not now""" - + @pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4")) + def test_bw_compression_w_rgb(self, compression, tmp_path): im = hopper("RGB") out = str(tmp_path / "temp.tif") with pytest.raises(OSError): - im.save(out, compression="tiff_ccitt") - with pytest.raises(OSError): - im.save(out, compression="group3") - with pytest.raises(OSError): - im.save(out, compression="group4") + im.save(out, compression=compression) def test_fp_leak(self): im = Image.open("Tests/images/hopper_g4_500.tif") @@ -832,6 +826,44 @@ class TestFileLibTiff(LibTiffTestCase): assert reloaded.mode == "F" assert reloaded.getexif()[SAMPLEFORMAT] == 3 + def test_lzma(self, capfd): + try: + with Image.open("Tests/images/hopper_lzma.tif") as im: + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == "TIFF" + im2 = hopper() + assert_image_similar(im, im2, 5) + except OSError: + captured = capfd.readouterr() + if "LZMA compression support is not configured" in captured.err: + pytest.skip("LZMA compression support is not configured") + sys.stdout.write(captured.out) + sys.stderr.write(captured.err) + raise + + def test_webp(self, capfd): + try: + with Image.open("Tests/images/hopper_webp.tif") as im: + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == "TIFF" + assert_image_similar_tofile(im, "Tests/images/hopper_webp.png", 1) + except OSError: + captured = capfd.readouterr() + if "WEBP compression support is not configured" in captured.err: + pytest.skip("WEBP compression support is not configured") + if ( + "Compression scheme 50001 strip decoding is not implemented" + in captured.err + ): + pytest.skip( + "Compression scheme 50001 strip decoding is not implemented" + ) + sys.stdout.write(captured.out) + sys.stderr.write(captured.err) + raise + def test_lzw(self): with Image.open("Tests/images/hopper_lzw.tif") as im: assert im.mode == "RGB" @@ -941,7 +973,7 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, exif=tags, compression=compression) with Image.open(out) as reloaded: - for tag in tags.keys(): + for tag in tags: assert tag not in reloaded.getexif() def test_old_style_jpeg(self): diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index e1c1c361b..be7c8d0c8 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -63,19 +63,7 @@ def test_p_mode(tmp_path): roundtrip(tmp_path, mode) -def test_l_oserror(tmp_path): - # Arrange - mode = "L" - - # Act / Assert - with pytest.raises(OSError): - helper_save_as_palm(tmp_path, mode) - - -def test_rgb_oserror(tmp_path): - # Arrange - mode = "RGB" - - # Act / Assert +@pytest.mark.parametrize("mode", ("L", "RGB")) +def test_oserror(tmp_path, mode): with pytest.raises(OSError): helper_save_as_palm(tmp_path, mode) diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index ba6663cd3..485adf785 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -39,14 +39,14 @@ def test_invalid_file(): PcxImagePlugin.PcxImageFile(invalid_file) -def test_odd(tmp_path): +@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB")) +def test_odd(tmp_path, mode): # See issue #523, odd sized images should have a stride that's even. # Not that ImageMagick or GIMP write PCX that way. # We were not handling properly. - for mode in ("1", "L", "P", "RGB"): - # larger, odd sized images are better here to ensure that - # we handle interrupted scan lines properly. - _roundtrip(tmp_path, hopper(mode).resize((511, 511))) + # larger, odd sized images are better here to ensure that + # we handle interrupted scan lines properly. + _roundtrip(tmp_path, hopper(mode).resize((511, 511))) def test_odd_read(): diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index df0b7abe6..4129e8783 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -6,7 +6,7 @@ import time import pytest -from PIL import Image, PdfParser +from PIL import Image, PdfParser, features from .helper import hopper, mark_if_feature_version @@ -37,6 +37,11 @@ def helper_save_as_pdf(tmp_path, mode, **kwargs): return outfile +@pytest.mark.parametrize("mode", ("L", "P", "RGB", "CMYK")) +def test_save(tmp_path, mode): + helper_save_as_pdf(tmp_path, mode) + + @pytest.mark.valgrind_known_error(reason="Temporary skip") def test_monochrome(tmp_path): # Arrange @@ -44,39 +49,7 @@ def test_monochrome(tmp_path): # Act / Assert outfile = helper_save_as_pdf(tmp_path, mode) - assert os.path.getsize(outfile) < 5000 - - -def test_greyscale(tmp_path): - # Arrange - mode = "L" - - # Act / Assert - helper_save_as_pdf(tmp_path, mode) - - -def test_rgb(tmp_path): - # Arrange - mode = "RGB" - - # Act / Assert - helper_save_as_pdf(tmp_path, mode) - - -def test_p_mode(tmp_path): - # Arrange - mode = "P" - - # Act / Assert - helper_save_as_pdf(tmp_path, mode) - - -def test_cmyk_mode(tmp_path): - # Arrange - mode = "CMYK" - - # Act / Assert - helper_save_as_pdf(tmp_path, mode) + assert os.path.getsize(outfile) < (5000 if features.check("libtiff") else 15000) def test_unsupported_mode(tmp_path): diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 1af0223eb..37235fe6f 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -20,7 +20,7 @@ from .helper import ( ) try: - import defusedxml.ElementTree as ElementTree + from defusedxml import ElementTree except ImportError: ElementTree = None diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 5c6376caf..fbcbea6c6 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -240,8 +240,8 @@ def test_header_token_too_long(tmp_path): def test_truncated_file(tmp_path): # Test EOF in header path = str(tmp_path / "temp.pgm") - with open(path, "w") as f: - f.write("P6") + with open(path, "wb") as f: + f.write(b"P6") with pytest.raises(ValueError) as e: with Image.open(path): @@ -256,11 +256,11 @@ def test_truncated_file(tmp_path): im.load() -@pytest.mark.parametrize("maxval", (0, 65536)) +@pytest.mark.parametrize("maxval", (b"0", b"65536")) def test_invalid_maxval(maxval, tmp_path): path = str(tmp_path / "temp.ppm") - with open(path, "w") as f: - f.write("P6\n3 1 " + str(maxval)) + with open(path, "wb") as f: + f.write(b"P6\n3 1 " + maxval) with pytest.raises(ValueError) as e: with Image.open(path): @@ -283,13 +283,13 @@ def test_neg_ppm(): def test_mimetypes(tmp_path): path = str(tmp_path / "temp.pgm") - with open(path, "w") as f: - f.write("P4\n128 128\n255") + with open(path, "wb") as f: + f.write(b"P4\n128 128\n255") with Image.open(path) as im: assert im.get_format_mimetype() == "image/x-portable-bitmap" - with open(path, "w") as f: - f.write("PyCMYK\n128 128\n255") + with open(path, "wb") as f: + f.write(b"PyCMYK\n128 128\n255") with Image.open(path) as im: assert im.get_format_mimetype() == "image/x-portable-anymap" diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index cbbb7df1d..7d8b5139a 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -120,6 +120,18 @@ def test_save(tmp_path): assert test_im.size == (100, 100) +def test_small_palette(tmp_path): + im = Image.new("P", (1, 1)) + colors = [0, 0, 0] + im.putpalette(colors) + + out = str(tmp_path / "temp.tga") + im.save(out) + + with Image.open(out) as reloaded: + assert reloaded.getpalette() == colors + + def test_save_wrong_mode(tmp_path): im = hopper("PA") out = str(tmp_path / "temp.tga") diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8706cb950..a32e6a005 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -18,7 +18,7 @@ from .helper import ( ) try: - import defusedxml.ElementTree as ElementTree + from defusedxml import ElementTree except ImportError: ElementTree = None @@ -84,6 +84,24 @@ class TestFileTiff: with Image.open("Tests/images/multipage.tiff") as im: im.load() + @pytest.mark.parametrize( + "path, sizes", + ( + ("Tests/images/hopper.tif", ()), + ("Tests/images/child_ifd.tiff", (16, 8)), + ("Tests/images/child_ifd_jpeg.tiff", (20,)), + ), + ) + def test_get_child_images(self, path, sizes): + with Image.open(path) as im: + ims = im.get_child_images() + + assert len(ims) == len(sizes) + for i, im in enumerate(ims): + w = sizes[i] + expected = Image.new("RGB", (w, w), "#f00") + assert_image_similar(im, expected, 1) + def test_mac_tiff(self): # Read RGBa images from macOS [@PIL136] @@ -293,14 +311,17 @@ class TestFileTiff: with Image.open("Tests/images/hopper_unknown_pixel_mode.tif"): pass - def test_n_frames(self): - for path, n_frames in [ - ["Tests/images/multipage-lastframe.tif", 1], - ["Tests/images/multipage.tiff", 3], - ]: - with Image.open(path) as im: - assert im.n_frames == n_frames - assert im.is_animated == (n_frames != 1) + @pytest.mark.parametrize( + "path, n_frames", + ( + ("Tests/images/multipage-lastframe.tif", 1), + ("Tests/images/multipage.tiff", 3), + ), + ) + def test_n_frames(self, path, n_frames): + with Image.open(path) as im: + assert im.n_frames == n_frames + assert im.is_animated == (n_frames != 1) def test_eoferror(self): with Image.open("Tests/images/multipage-lastframe.tif") as im: @@ -416,12 +437,12 @@ class TestFileTiff: len_after = len(dict(im.ifd)) assert len_before == len_after + 1 - def test_load_byte(self): - for legacy_api in [False, True]: - ifd = TiffImagePlugin.ImageFileDirectory_v2() - data = b"abc" - ret = ifd.load_byte(data, legacy_api) - assert ret == b"abc" + @pytest.mark.parametrize("legacy_api", (False, True)) + def test_load_byte(self, legacy_api): + ifd = TiffImagePlugin.ImageFileDirectory_v2() + data = b"abc" + ret = ifd.load_byte(data, legacy_api) + assert ret == b"abc" def test_load_string(self): ifd = TiffImagePlugin.ImageFileDirectory_v2() @@ -667,18 +688,15 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert_image_equal_tofile(reloaded, infile) - def test_palette(self, tmp_path): - def roundtrip(mode): - outfile = str(tmp_path / "temp.tif") + @pytest.mark.parametrize("mode", ("P", "PA")) + def test_palette(self, mode, tmp_path): + outfile = str(tmp_path / "temp.tif") - im = hopper(mode) - im.save(outfile) + im = hopper(mode) + im.save(outfile) - with Image.open(outfile) as reloaded: - assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) - - for mode in ["P", "PA"]: - roundtrip(mode) + with Image.open(outfile) as reloaded: + assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) def test_tiff_save_all(self): mp = BytesIO() diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index d7a0d9377..d38c1c523 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -185,6 +185,22 @@ def test_iptc(tmp_path): im.save(out) +def test_writing_bytes_to_ascii(tmp_path): + im = hopper() + info = TiffImagePlugin.ImageFileDirectory_v2() + + tag = TiffTags.TAGS_V2[271] + assert tag.type == TiffTags.ASCII + + info[271] = b"test" + + out = str(tmp_path / "temp.tiff") + im.save(out, tiffinfo=info) + + with Image.open(out) as reloaded: + assert reloaded.tag_v2[271] == "test" + + def test_undefined_zero(tmp_path): # Check that the tag has not been changed since this test was created tag = TiffTags.TAGS_V2[45059] diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index e6d6fc63f..f77a245c0 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -55,9 +55,7 @@ def test_write_exif_metadata(): test_buffer.seek(0) with Image.open(test_buffer) as webp_image: webp_exif = webp_image.info.get("exif", None) - assert webp_exif - if webp_exif: - assert webp_exif == expected_exif, "WebP EXIF didn't match" + assert webp_exif == expected_exif[6:], "WebP EXIF didn't match" def test_read_icc_profile(): diff --git a/Tests/test_font_pcf_charsets.py b/Tests/test_font_pcf_charsets.py index 4477ee29d..664663fd6 100644 --- a/Tests/test_font_pcf_charsets.py +++ b/Tests/test_font_pcf_charsets.py @@ -1,5 +1,7 @@ import os +import pytest + from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile from .helper import ( @@ -59,23 +61,13 @@ def save_font(request, tmp_path, encoding): return tempname -def _test_sanity(request, tmp_path, encoding): +@pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) +def test_sanity(request, tmp_path, encoding): save_font(request, tmp_path, encoding) -def test_sanity_iso8859_1(request, tmp_path): - _test_sanity(request, tmp_path, "iso8859-1") - - -def test_sanity_iso8859_2(request, tmp_path): - _test_sanity(request, tmp_path, "iso8859-2") - - -def test_sanity_cp1250(request, tmp_path): - _test_sanity(request, tmp_path, "cp1250") - - -def _test_draw(request, tmp_path, encoding): +@pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) +def test_draw(request, tmp_path, encoding): tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) im = Image.new("L", (150, 30), "white") @@ -85,19 +77,8 @@ def _test_draw(request, tmp_path, encoding): assert_image_similar_tofile(im, charsets[encoding]["image1"], 0) -def test_draw_iso8859_1(request, tmp_path): - _test_draw(request, tmp_path, "iso8859-1") - - -def test_draw_iso8859_2(request, tmp_path): - _test_draw(request, tmp_path, "iso8859-2") - - -def test_draw_cp1250(request, tmp_path): - _test_draw(request, tmp_path, "cp1250") - - -def _test_textsize(request, tmp_path, encoding): +@pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) +def test_textsize(request, tmp_path, encoding): tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) for i in range(255): @@ -112,15 +93,3 @@ def _test_textsize(request, tmp_path, encoding): msg = message[: i + 1] assert font.getlength(msg) == len(msg) * 10 assert font.getbbox(msg) == (0, 0, len(msg) * 10, 20) - - -def test_textsize_iso8859_1(request, tmp_path): - _test_textsize(request, tmp_path, "iso8859-1") - - -def test_textsize_iso8859_2(request, tmp_path): - _test_textsize(request, tmp_path, "iso8859-2") - - -def test_textsize_cp1250(request, tmp_path): - _test_textsize(request, tmp_path, "cp1250") diff --git a/Tests/test_image.py b/Tests/test_image.py index 7cebed127..e57903490 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -129,8 +129,6 @@ class TestImage: im.size = (3, 4) def test_invalid_image(self): - import io - im = io.BytesIO(b"") with pytest.raises(UnidentifiedImageError): with Image.open(im): @@ -620,6 +618,7 @@ class TestImage: im_remapped = im.remap_palette([1, 0]) assert im_remapped.info["transparency"] == 1 + assert len(im_remapped.getpalette()) == 6 # Test unused transparency im.info["transparency"] = 2 @@ -698,15 +697,15 @@ class TestImage: def test_empty_exif(self): with Image.open("Tests/images/exif.png") as im: exif = im.getexif() - assert dict(exif) != {} + assert dict(exif) # Test that exif data is cleared after another load exif.load(None) - assert dict(exif) == {} + assert not dict(exif) # Test loading just the EXIF header exif.load(b"Exif\x00\x00") - assert dict(exif) == {} + assert not dict(exif) @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index bb75eb0b5..955740b95 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -131,8 +131,7 @@ class TestImageGetPixel(AccessTest): bands = Image.getmodebands(mode) if bands == 1: return 1 - else: - return tuple(range(1, bands + 1)) + return tuple(range(1, bands + 1)) def check(self, mode, c=None): if not c: @@ -215,11 +214,14 @@ class TestImageGetPixel(AccessTest): self.check(mode, 2**15 + 1) self.check(mode, 2**16 - 1) + @pytest.mark.parametrize("mode", ("P", "PA")) @pytest.mark.parametrize("color", ((255, 0, 0), (255, 0, 0, 255))) - def test_p_putpixel_rgb_rgba(self, color): - im = Image.new("P", (1, 1), 0) + def test_p_putpixel_rgb_rgba(self, mode, color): + im = Image.new(mode, (1, 1)) im.putpixel((0, 0), color) - assert im.convert("RGB").getpixel((0, 0)) == (255, 0, 0) + + alpha = color[3] if len(color) == 4 and mode == "PA" else 255 + assert im.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, alpha) @pytest.mark.skipif(cffi is None, reason="No CFFI") @@ -340,12 +342,16 @@ class TestCffi(AccessTest): # pixels can contain garbage if image is released assert px[i, 0] == 0 - def test_p_putpixel_rgb_rgba(self): - for color in [(255, 0, 0), (255, 0, 0, 255)]: - im = Image.new("P", (1, 1), 0) + @pytest.mark.parametrize("mode", ("P", "PA")) + def test_p_putpixel_rgb_rgba(self, mode): + for color in ((255, 0, 0), (255, 0, 0, 127 if mode == "PA" else 255)): + im = Image.new(mode, (1, 1)) access = PyAccess.new(im, False) access.putpixel((0, 0), color) - assert im.convert("RGB").getpixel((0, 0)) == (255, 0, 0) + + if len(color) == 3: + color += (255,) + assert im.convert("RGBA").getpixel((0, 0)) == color class TestImagePutPixelError(AccessTest): @@ -408,7 +414,7 @@ class TestEmbeddable: def test_embeddable(self): import ctypes - with open("embed_pil.c", "w") as fh: + with open("embed_pil.c", "w", encoding="utf-8") as fh: fh.write( """ #include "Python.h" diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 7e5fd6fe1..ae3518e44 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -35,10 +35,13 @@ def test_toarray(): test_with_dtype(numpy.float64) test_with_dtype(numpy.uint8) - if parse_version(numpy.__version__) >= parse_version("1.23"): - with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated: + with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated: + if parse_version(numpy.__version__) >= parse_version("1.23"): with pytest.raises(OSError): numpy.array(im_truncated) + else: + with pytest.warns(UserWarning): + numpy.array(im_truncated) def test_fromarray(): diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 8f4b8b43c..902d8bf8f 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -38,6 +38,12 @@ def test_sanity(): convert(im, output_mode) +def test_unsupported_conversion(): + im = hopper() + with pytest.raises(ValueError): + im.convert("INVALID") + + def test_default(): im = hopper("P") @@ -236,6 +242,23 @@ def test_p2pa_alpha(): assert im_a.getpixel((x, y)) == alpha +def test_p2pa_palette(): + with Image.open("Tests/images/tiny.png") as im: + im_pa = im.convert("PA") + assert im_pa.getpalette() == im.getpalette() + + +@pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX")) +def test_rgb_lab(mode): + im = Image.new(mode, (1, 1)) + converted_im = im.convert("LAB") + assert converted_im.getpixel((0, 0)) == (0, 128, 128) + + im = Image.new("LAB", (1, 1), (255, 0, 0)) + converted_im = im.convert(mode) + assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) + + def test_matrix_illegal_conversion(): # Arrange im = hopper("CMYK") diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 14a8da9f1..cfe46b658 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -5,90 +5,109 @@ from PIL import Image, ImageFilter from .helper import assert_image_equal, hopper -def test_sanity(): - def apply_filter(filter_to_apply): - for mode in ["L", "RGB", "CMYK"]: - im = hopper(mode) - out = im.filter(filter_to_apply) - assert out.mode == im.mode - assert out.size == im.size +@pytest.mark.parametrize( + "filter_to_apply", + ( + ImageFilter.BLUR, + ImageFilter.CONTOUR, + ImageFilter.DETAIL, + ImageFilter.EDGE_ENHANCE, + ImageFilter.EDGE_ENHANCE_MORE, + ImageFilter.EMBOSS, + ImageFilter.FIND_EDGES, + ImageFilter.SMOOTH, + ImageFilter.SMOOTH_MORE, + ImageFilter.SHARPEN, + ImageFilter.MaxFilter, + ImageFilter.MedianFilter, + ImageFilter.MinFilter, + ImageFilter.ModeFilter, + ImageFilter.GaussianBlur, + ImageFilter.GaussianBlur(5), + ImageFilter.BoxBlur(5), + ImageFilter.UnsharpMask, + ImageFilter.UnsharpMask(10), + ), +) +@pytest.mark.parametrize("mode", ("L", "RGB", "CMYK")) +def test_sanity(filter_to_apply, mode): + im = hopper(mode) + out = im.filter(filter_to_apply) + assert out.mode == im.mode + assert out.size == im.size - apply_filter(ImageFilter.BLUR) - apply_filter(ImageFilter.CONTOUR) - apply_filter(ImageFilter.DETAIL) - apply_filter(ImageFilter.EDGE_ENHANCE) - apply_filter(ImageFilter.EDGE_ENHANCE_MORE) - apply_filter(ImageFilter.EMBOSS) - apply_filter(ImageFilter.FIND_EDGES) - apply_filter(ImageFilter.SMOOTH) - apply_filter(ImageFilter.SMOOTH_MORE) - apply_filter(ImageFilter.SHARPEN) - apply_filter(ImageFilter.MaxFilter) - apply_filter(ImageFilter.MedianFilter) - apply_filter(ImageFilter.MinFilter) - apply_filter(ImageFilter.ModeFilter) - apply_filter(ImageFilter.GaussianBlur) - apply_filter(ImageFilter.GaussianBlur(5)) - apply_filter(ImageFilter.BoxBlur(5)) - apply_filter(ImageFilter.UnsharpMask) - apply_filter(ImageFilter.UnsharpMask(10)) +@pytest.mark.parametrize("mode", ("L", "RGB", "CMYK")) +def test_sanity_error(mode): with pytest.raises(TypeError): - apply_filter("hello") + im = hopper(mode) + im.filter("hello") -def test_crash(): - - # crashes on small images - im = Image.new("RGB", (1, 1)) - im.filter(ImageFilter.SMOOTH) - - im = Image.new("RGB", (2, 2)) - im.filter(ImageFilter.SMOOTH) - - im = Image.new("RGB", (3, 3)) +# crashes on small images +@pytest.mark.parametrize("size", ((1, 1), (2, 2), (3, 3))) +def test_crash(size): + im = Image.new("RGB", size) im.filter(ImageFilter.SMOOTH) -def test_modefilter(): - def modefilter(mode): - im = Image.new(mode, (3, 3), None) - im.putdata(list(range(9))) - # image is: - # 0 1 2 - # 3 4 5 - # 6 7 8 - mod = im.filter(ImageFilter.ModeFilter).getpixel((1, 1)) - im.putdata([0, 0, 1, 2, 5, 1, 5, 2, 0]) # mode=0 - mod2 = im.filter(ImageFilter.ModeFilter).getpixel((1, 1)) - return mod, mod2 - - assert modefilter("1") == (4, 0) - assert modefilter("L") == (4, 0) - assert modefilter("P") == (4, 0) - assert modefilter("RGB") == ((4, 0, 0), (0, 0, 0)) +@pytest.mark.parametrize( + "mode, expected", + ( + ("1", (4, 0)), + ("L", (4, 0)), + ("P", (4, 0)), + ("RGB", ((4, 0, 0), (0, 0, 0))), + ), +) +def test_modefilter(mode, expected): + im = Image.new(mode, (3, 3), None) + im.putdata(list(range(9))) + # image is: + # 0 1 2 + # 3 4 5 + # 6 7 8 + mod = im.filter(ImageFilter.ModeFilter).getpixel((1, 1)) + im.putdata([0, 0, 1, 2, 5, 1, 5, 2, 0]) # mode=0 + mod2 = im.filter(ImageFilter.ModeFilter).getpixel((1, 1)) + assert (mod, mod2) == expected -def test_rankfilter(): - def rankfilter(mode): - im = Image.new(mode, (3, 3), None) - im.putdata(list(range(9))) - # image is: - # 0 1 2 - # 3 4 5 - # 6 7 8 - minimum = im.filter(ImageFilter.MinFilter).getpixel((1, 1)) - med = im.filter(ImageFilter.MedianFilter).getpixel((1, 1)) - maximum = im.filter(ImageFilter.MaxFilter).getpixel((1, 1)) - return minimum, med, maximum +@pytest.mark.parametrize( + "mode, expected", + ( + ("1", (0, 4, 8)), + ("L", (0, 4, 8)), + ("RGB", ((0, 0, 0), (4, 0, 0), (8, 0, 0))), + ("I", (0, 4, 8)), + ("F", (0.0, 4.0, 8.0)), + ), +) +def test_rankfilter(mode, expected): + im = Image.new(mode, (3, 3), None) + im.putdata(list(range(9))) + # image is: + # 0 1 2 + # 3 4 5 + # 6 7 8 + minimum = im.filter(ImageFilter.MinFilter).getpixel((1, 1)) + med = im.filter(ImageFilter.MedianFilter).getpixel((1, 1)) + maximum = im.filter(ImageFilter.MaxFilter).getpixel((1, 1)) + assert (minimum, med, maximum) == expected - assert rankfilter("1") == (0, 4, 8) - assert rankfilter("L") == (0, 4, 8) + +@pytest.mark.parametrize( + "filter", (ImageFilter.MinFilter, ImageFilter.MedianFilter, ImageFilter.MaxFilter) +) +def test_rankfilter_error(filter): with pytest.raises(ValueError): - rankfilter("P") - assert rankfilter("RGB") == ((0, 0, 0), (4, 0, 0), (8, 0, 0)) - assert rankfilter("I") == (0, 4, 8) - assert rankfilter("F") == (0.0, 4.0, 8.0) + im = Image.new("P", (3, 3), None) + im.putdata(list(range(9))) + # image is: + # 0 1 2 + # 3 4 5 + # 6 7 8 + im.filter(filter).getpixel((1, 1)) def test_rankfilter_properties(): @@ -110,7 +129,8 @@ def test_kernel_not_enough_coefficients(): ImageFilter.Kernel((3, 3), (0, 0)) -def test_consistency_3x3(): +@pytest.mark.parametrize("mode", ("L", "LA", "RGB", "CMYK")) +def test_consistency_3x3(mode): with Image.open("Tests/images/hopper.bmp") as source: with Image.open("Tests/images/hopper_emboss.bmp") as reference: kernel = ImageFilter.Kernel( @@ -125,14 +145,14 @@ def test_consistency_3x3(): source = source.split() * 2 reference = reference.split() * 2 - for mode in ["L", "LA", "RGB", "CMYK"]: - assert_image_equal( - Image.merge(mode, source[: len(mode)]).filter(kernel), - Image.merge(mode, reference[: len(mode)]), - ) + assert_image_equal( + Image.merge(mode, source[: len(mode)]).filter(kernel), + Image.merge(mode, reference[: len(mode)]), + ) -def test_consistency_5x5(): +@pytest.mark.parametrize("mode", ("L", "LA", "RGB", "CMYK")) +def test_consistency_5x5(mode): with Image.open("Tests/images/hopper.bmp") as source: with Image.open("Tests/images/hopper_emboss_more.bmp") as reference: kernel = ImageFilter.Kernel( @@ -149,8 +169,7 @@ def test_consistency_5x5(): source = source.split() * 2 reference = reference.split() * 2 - for mode in ["L", "LA", "RGB", "CMYK"]: - assert_image_equal( - Image.merge(mode, source[: len(mode)]).filter(kernel), - Image.merge(mode, reference[: len(mode)]), - ) + assert_image_equal( + Image.merge(mode, source[: len(mode)]).filter(kernel), + Image.merge(mode, reference[: len(mode)]), + ) diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index 70dc87f0a..ae8d740a0 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -38,58 +38,64 @@ gradients_image = Image.open("Tests/images/radial_gradients.png") gradients_image.load() -def test_args_factor(): +@pytest.mark.parametrize( + "size, expected", + ( + (3, (4, 4)), + ((3, 1), (4, 10)), + ((1, 3), (10, 4)), + ), +) +def test_args_factor(size, expected): im = Image.new("L", (10, 10)) - - assert (4, 4) == im.reduce(3).size - assert (4, 10) == im.reduce((3, 1)).size - assert (10, 4) == im.reduce((1, 3)).size - - with pytest.raises(ValueError): - im.reduce(0) - with pytest.raises(TypeError): - im.reduce(2.0) - with pytest.raises(ValueError): - im.reduce((0, 10)) + assert expected == im.reduce(size).size -def test_args_box(): +@pytest.mark.parametrize( + "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) +) +def test_args_factor_error(size, expected_error): im = Image.new("L", (10, 10)) - - assert (5, 5) == im.reduce(2, (0, 0, 10, 10)).size - assert (1, 1) == im.reduce(2, (5, 5, 6, 6)).size - - with pytest.raises(TypeError): - im.reduce(2, "stri") - with pytest.raises(TypeError): - im.reduce(2, 2) - with pytest.raises(ValueError): - im.reduce(2, (0, 0, 11, 10)) - with pytest.raises(ValueError): - im.reduce(2, (0, 0, 10, 11)) - with pytest.raises(ValueError): - im.reduce(2, (-1, 0, 10, 10)) - with pytest.raises(ValueError): - im.reduce(2, (0, -1, 10, 10)) - with pytest.raises(ValueError): - im.reduce(2, (0, 5, 10, 5)) - with pytest.raises(ValueError): - im.reduce(2, (5, 0, 5, 10)) + with pytest.raises(expected_error): + im.reduce(size) -def test_unsupported_modes(): +@pytest.mark.parametrize( + "size, expected", + ( + ((0, 0, 10, 10), (5, 5)), + ((5, 5, 6, 6), (1, 1)), + ), +) +def test_args_box(size, expected): + im = Image.new("L", (10, 10)) + assert expected == im.reduce(2, size).size + + +@pytest.mark.parametrize( + "size, expected_error", + ( + ("stri", TypeError), + ((0, 0, 11, 10), ValueError), + ((0, 0, 10, 11), ValueError), + ((-1, 0, 10, 10), ValueError), + ((0, -1, 10, 10), ValueError), + ((0, 5, 10, 5), ValueError), + ((5, 0, 5, 10), ValueError), + ), +) +def test_args_box_error(size, expected_error): + im = Image.new("L", (10, 10)) + with pytest.raises(expected_error): + im.reduce(2, size).size + + +@pytest.mark.parametrize("mode", ("P", "1", "I;16")) +def test_unsupported_modes(mode): im = Image.new("P", (10, 10)) with pytest.raises(ValueError): im.reduce(3) - im = Image.new("1", (10, 10)) - with pytest.raises(ValueError): - im.reduce(3) - - im = Image.new("I;16", (10, 10)) - with pytest.raises(ValueError): - im.reduce(3) - def get_image(mode): mode_info = ImageMode.getmode(mode) @@ -190,70 +196,76 @@ def assert_compare_images(a, b, max_average_diff, max_diff=255): ) -def test_mode_L(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_L(factor): im = get_image("L") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_LA(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_LA(factor): im = get_image("LA") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor, 0.8, 5) + compare_reduce_with_reference(im, factor, 0.8, 5) + +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_LA_opaque(factor): + im = get_image("LA") # With opaque alpha, an error should be way smaller. im.putalpha(Image.new("L", im.size, 255)) - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_La(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_La(factor): im = get_image("La") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_RGB(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_RGB(factor): im = get_image("RGB") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_RGBA(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_RGBA(factor): im = get_image("RGBA") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor, 0.8, 5) + compare_reduce_with_reference(im, factor, 0.8, 5) + +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_RGBA_opaque(factor): + im = get_image("RGBA") # With opaque alpha, an error should be way smaller. im.putalpha(Image.new("L", im.size, 255)) - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_RGBa(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_RGBa(factor): im = get_image("RGBa") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_I(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_I(factor): im = get_image("I") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor) + compare_reduce_with_box(im, factor) -def test_mode_F(): +@pytest.mark.parametrize("factor", remarkable_factors) +def test_mode_F(factor): im = get_image("F") - for factor in remarkable_factors: - compare_reduce_with_reference(im, factor, 0, 0) - compare_reduce_with_box(im, factor) + compare_reduce_with_reference(im, factor, 0, 0) + compare_reduce_with_box(im, factor) @skip_unless_feature("jpg_2000") diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 5ce98a235..53ceb6df0 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -554,44 +554,48 @@ class TestCoreResampleBox: # check that the difference at least that much assert_image_similar(res, im.crop(box), 20, f">>> {size} {box}") - def test_skip_horizontal(self): + @pytest.mark.parametrize( + "flt", (Image.Resampling.NEAREST, Image.Resampling.BICUBIC) + ) + def test_skip_horizontal(self, flt): # Can skip resize for one dimension im = hopper() - for flt in [Image.Resampling.NEAREST, Image.Resampling.BICUBIC]: - for size, box in [ - ((40, 50), (0, 0, 40, 90)), - ((40, 50), (0, 20, 40, 90)), - ((40, 50), (10, 0, 50, 90)), - ((40, 50), (10, 20, 50, 90)), - ]: - res = im.resize(size, flt, box) - assert res.size == size - # Borders should be slightly different - assert_image_similar( - res, - im.crop(box).resize(size, flt), - 0.4, - f">>> {size} {box} {flt}", - ) + for size, box in [ + ((40, 50), (0, 0, 40, 90)), + ((40, 50), (0, 20, 40, 90)), + ((40, 50), (10, 0, 50, 90)), + ((40, 50), (10, 20, 50, 90)), + ]: + res = im.resize(size, flt, box) + assert res.size == size + # Borders should be slightly different + assert_image_similar( + res, + im.crop(box).resize(size, flt), + 0.4, + f">>> {size} {box} {flt}", + ) - def test_skip_vertical(self): + @pytest.mark.parametrize( + "flt", (Image.Resampling.NEAREST, Image.Resampling.BICUBIC) + ) + def test_skip_vertical(self, flt): # Can skip resize for one dimension im = hopper() - for flt in [Image.Resampling.NEAREST, Image.Resampling.BICUBIC]: - for size, box in [ - ((40, 50), (0, 0, 90, 50)), - ((40, 50), (20, 0, 90, 50)), - ((40, 50), (0, 10, 90, 60)), - ((40, 50), (20, 10, 90, 60)), - ]: - res = im.resize(size, flt, box) - assert res.size == size - # Borders should be slightly different - assert_image_similar( - res, - im.crop(box).resize(size, flt), - 0.4, - f">>> {size} {box} {flt}", - ) + for size, box in [ + ((40, 50), (0, 0, 90, 50)), + ((40, 50), (20, 0, 90, 50)), + ((40, 50), (0, 10, 90, 60)), + ((40, 50), (20, 10, 90, 60)), + ]: + res = im.resize(size, flt, box) + assert res.size == size + # Borders should be slightly different + assert_image_similar( + res, + im.crop(box).resize(size, flt), + 0.4, + f">>> {size} {box} {flt}", + ) diff --git a/Tests/test_image_split.py b/Tests/test_image_split.py index fbed276b8..5cb7c9a8b 100644 --- a/Tests/test_image_split.py +++ b/Tests/test_image_split.py @@ -1,3 +1,5 @@ +import pytest + from PIL import Image, features from .helper import assert_image_equal, hopper @@ -29,19 +31,12 @@ def test_split(): assert split("YCbCr") == [("L", 128, 128), ("L", 128, 128), ("L", 128, 128)] -def test_split_merge(): - def split_merge(mode): - return Image.merge(mode, hopper(mode).split()) - - assert_image_equal(hopper("1"), split_merge("1")) - assert_image_equal(hopper("L"), split_merge("L")) - assert_image_equal(hopper("I"), split_merge("I")) - assert_image_equal(hopper("F"), split_merge("F")) - assert_image_equal(hopper("P"), split_merge("P")) - assert_image_equal(hopper("RGB"), split_merge("RGB")) - assert_image_equal(hopper("RGBA"), split_merge("RGBA")) - assert_image_equal(hopper("CMYK"), split_merge("CMYK")) - assert_image_equal(hopper("YCbCr"), split_merge("YCbCr")) +@pytest.mark.parametrize( + "mode", ("1", "L", "I", "F", "P", "RGB", "RGBA", "CMYK", "YCbCr") +) +def test_split_merge(mode): + expected = Image.merge(mode, hopper(mode).split()) + assert_image_equal(hopper(mode), expected) def test_split_open(tmp_path): diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 20cc101ed..4fd07a2b4 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -97,6 +97,28 @@ def test_load_first(): im.thumbnail((64, 64)) assert im.size == (64, 10) + # Test thumbnail(), without draft(), + # on an image that is large enough once load() has changed the size + with Image.open("Tests/images/g4_orientation_5.tif") as im: + im.thumbnail((590, 88), reducing_gap=None) + assert im.size == (590, 88) + + +def test_load_first_unless_jpeg(): + # Test that thumbnail() still uses draft() for JPEG + with Image.open("Tests/images/hopper.jpg") as im: + draft = im.draft + + def im_draft(mode, size): + result = draft(mode, size) + assert result is not None + + return result + + im.draft = im_draft + + im.thumbnail((64, 64)) + # valgrind test is failing with memory allocated in libjpeg @pytest.mark.valgrind_known_error(reason="Known Failing") diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index ac0e74969..a78349801 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -75,23 +75,25 @@ class TestImageTransform: assert_image_equal(transformed, scaled) - def test_fill(self): - for mode, pixel in [ - ["RGB", (255, 0, 0)], - ["RGBA", (255, 0, 0, 255)], - ["LA", (76, 0)], - ]: - im = hopper(mode) - (w, h) = im.size - transformed = im.transform( - im.size, - Image.Transform.EXTENT, - (0, 0, w * 2, h * 2), - Image.Resampling.BILINEAR, - fillcolor="red", - ) - - assert transformed.getpixel((w - 1, h - 1)) == pixel + @pytest.mark.parametrize( + "mode, expected_pixel", + ( + ("RGB", (255, 0, 0)), + ("RGBA", (255, 0, 0, 255)), + ("LA", (76, 0)), + ), + ) + def test_fill(self, mode, expected_pixel): + im = hopper(mode) + (w, h) = im.size + transformed = im.transform( + im.size, + Image.Transform.EXTENT, + (0, 0, w * 2, h * 2), + Image.Resampling.BILINEAR, + fillcolor="red", + ) + assert transformed.getpixel((w - 1, h - 1)) == expected_pixel def test_mesh(self): # this should be a checkerboard of halfsized hoppers in ul, lr @@ -222,14 +224,12 @@ class TestImageTransform: with pytest.raises(ValueError): im.transform((100, 100), None) - def test_unknown_resampling_filter(self): + @pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown")) + def test_unknown_resampling_filter(self, resample): with hopper() as im: (w, h) = im.size - for resample in (Image.Resampling.BOX, "unknown"): - with pytest.raises(ValueError): - im.transform( - (100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample - ) + with pytest.raises(ValueError): + im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) class TestImageTransformAffine: @@ -239,7 +239,16 @@ class TestImageTransformAffine: im = hopper("RGB") return im.crop((10, 20, im.width - 10, im.height - 20)) - def _test_rotate(self, deg, transpose): + @pytest.mark.parametrize( + "deg, transpose", + ( + (0, None), + (90, Image.Transpose.ROTATE_90), + (180, Image.Transpose.ROTATE_180), + (270, Image.Transpose.ROTATE_270), + ), + ) + def test_rotate(self, deg, transpose): im = self._test_image() angle = -math.radians(deg) @@ -271,77 +280,65 @@ class TestImageTransformAffine: ) assert_image_equal(transposed, transformed) - def test_rotate_0_deg(self): - self._test_rotate(0, None) - - def test_rotate_90_deg(self): - self._test_rotate(90, Image.Transpose.ROTATE_90) - - def test_rotate_180_deg(self): - self._test_rotate(180, Image.Transpose.ROTATE_180) - - def test_rotate_270_deg(self): - self._test_rotate(270, Image.Transpose.ROTATE_270) - - def _test_resize(self, scale, epsilonscale): + @pytest.mark.parametrize( + "scale, epsilon_scale", + ( + (1.1, 6.9), + (1.5, 5.5), + (2.0, 5.5), + (2.3, 3.7), + (2.5, 3.7), + ), + ) + @pytest.mark.parametrize( + "resample,epsilon", + ( + (Image.Resampling.NEAREST, 0), + (Image.Resampling.BILINEAR, 2), + (Image.Resampling.BICUBIC, 1), + ), + ) + def test_resize(self, scale, epsilon_scale, resample, epsilon): im = self._test_image() size_up = int(round(im.width * scale)), int(round(im.height * scale)) matrix_up = [1 / scale, 0, 0, 0, 1 / scale, 0, 0, 0] matrix_down = [scale, 0, 0, 0, scale, 0, 0, 0] - for resample, epsilon in [ + transformed = im.transform(size_up, self.transform, matrix_up, resample) + transformed = transformed.transform( + im.size, self.transform, matrix_down, resample + ) + assert_image_similar(transformed, im, epsilon * epsilon_scale) + + @pytest.mark.parametrize( + "x, y, epsilon_scale", + ( + (0.1, 0, 3.7), + (0.6, 0, 9.1), + (50, 50, 0), + ), + ) + @pytest.mark.parametrize( + "resample, epsilon", + ( (Image.Resampling.NEAREST, 0), - (Image.Resampling.BILINEAR, 2), + (Image.Resampling.BILINEAR, 1.5), (Image.Resampling.BICUBIC, 1), - ]: - transformed = im.transform(size_up, self.transform, matrix_up, resample) - transformed = transformed.transform( - im.size, self.transform, matrix_down, resample - ) - assert_image_similar(transformed, im, epsilon * epsilonscale) - - def test_resize_1_1x(self): - self._test_resize(1.1, 6.9) - - def test_resize_1_5x(self): - self._test_resize(1.5, 5.5) - - def test_resize_2_0x(self): - self._test_resize(2.0, 5.5) - - def test_resize_2_3x(self): - self._test_resize(2.3, 3.7) - - def test_resize_2_5x(self): - self._test_resize(2.5, 3.7) - - def _test_translate(self, x, y, epsilonscale): + ), + ) + def test_translate(self, x, y, epsilon_scale, resample, epsilon): im = self._test_image() size_up = int(round(im.width + x)), int(round(im.height + y)) matrix_up = [1, 0, -x, 0, 1, -y, 0, 0] matrix_down = [1, 0, x, 0, 1, y, 0, 0] - for resample, epsilon in [ - (Image.Resampling.NEAREST, 0), - (Image.Resampling.BILINEAR, 1.5), - (Image.Resampling.BICUBIC, 1), - ]: - transformed = im.transform(size_up, self.transform, matrix_up, resample) - transformed = transformed.transform( - im.size, self.transform, matrix_down, resample - ) - assert_image_similar(transformed, im, epsilon * epsilonscale) - - def test_translate_0_1(self): - self._test_translate(0.1, 0, 3.7) - - def test_translate_0_6(self): - self._test_translate(0.6, 0, 9.1) - - def test_translate_50(self): - self._test_translate(50, 50, 0) + transformed = im.transform(size_up, self.transform, matrix_up, resample) + transformed = transformed.transform( + im.size, self.transform, matrix_down, resample + ) + assert_image_similar(transformed, im, epsilon * epsilon_scale) class TestImageTransformPerspective(TestImageTransformAffine): diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index d1dd1e47c..76b7c65cc 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -64,7 +64,9 @@ def test_mode_mismatch(): ImageDraw.ImageDraw(im, mode="L") -def helper_arc(bbox, start, end): +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) +def test_arc(bbox, start, end): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -76,16 +78,6 @@ def helper_arc(bbox, start, end): assert_image_similar_tofile(im, "Tests/images/imagedraw_arc.png", 1) -def test_arc1(): - helper_arc(BBOX1, 0, 180) - helper_arc(BBOX1, 0.5, 180.4) - - -def test_arc2(): - helper_arc(BBOX2, 0, 180) - helper_arc(BBOX2, 0.5, 180.4) - - def test_arc_end_le_start(): # Arrange im = Image.new("RGB", (W, H)) @@ -192,29 +184,21 @@ def test_bitmap(): assert_image_equal_tofile(im, "Tests/images/imagedraw_bitmap.png") -def helper_chord(mode, bbox, start, end): +@pytest.mark.parametrize("mode", ("RGB", "L")) +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +def test_chord(mode, bbox): # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) expected = f"Tests/images/imagedraw_chord_{mode}.png" # Act - draw.chord(bbox, start, end, fill="red", outline="yellow") + draw.chord(bbox, 0, 180, fill="red", outline="yellow") # Assert assert_image_similar_tofile(im, expected, 1) -def test_chord1(): - for mode in ["RGB", "L"]: - helper_chord(mode, BBOX1, 0, 180) - - -def test_chord2(): - for mode in ["RGB", "L"]: - helper_chord(mode, BBOX2, 0, 180) - - def test_chord_width(): # Arrange im = Image.new("RGB", (W, H)) @@ -263,7 +247,9 @@ def test_chord_too_fat(): assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png") -def helper_ellipse(mode, bbox): +@pytest.mark.parametrize("mode", ("RGB", "L")) +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +def test_ellipse(mode, bbox): # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -276,16 +262,6 @@ def helper_ellipse(mode, bbox): assert_image_similar_tofile(im, expected, 1) -def test_ellipse1(): - for mode in ["RGB", "L"]: - helper_ellipse(mode, BBOX1) - - -def test_ellipse2(): - for mode in ["RGB", "L"]: - helper_ellipse(mode, BBOX2) - - def test_ellipse_translucent(): # Arrange im = Image.new("RGB", (W, H)) @@ -405,7 +381,8 @@ def test_ellipse_various_sizes_filled(): ) -def helper_line(points): +@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +def test_line(points): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -417,14 +394,6 @@ def helper_line(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") -def test_line1(): - helper_line(POINTS1) - - -def test_line2(): - helper_line(POINTS2) - - def test_shape1(): # Arrange im = Image.new("RGB", (100, 100), "white") @@ -484,7 +453,9 @@ def test_transform(): assert_image_equal(im, expected) -def helper_pieslice(bbox, start, end): +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) +def test_pieslice(bbox, start, end): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -496,16 +467,6 @@ def helper_pieslice(bbox, start, end): assert_image_similar_tofile(im, "Tests/images/imagedraw_pieslice.png", 1) -def test_pieslice1(): - helper_pieslice(BBOX1, -92, 46) - helper_pieslice(BBOX1, -92.2, 46.2) - - -def test_pieslice2(): - helper_pieslice(BBOX2, -92, 46) - helper_pieslice(BBOX2, -92.2, 46.2) - - def test_pieslice_width(): # Arrange im = Image.new("RGB", (W, H)) @@ -585,7 +546,8 @@ def test_pieslice_no_spikes(): assert_image_equal(im, im_pre_erase) -def helper_point(points): +@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +def test_point(points): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -597,15 +559,8 @@ def helper_point(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_point.png") -def test_point1(): - helper_point(POINTS1) - - -def test_point2(): - helper_point(POINTS2) - - -def helper_polygon(points): +@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +def test_polygon(points): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -617,14 +572,6 @@ def helper_polygon(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon.png") -def test_polygon1(): - helper_polygon(POINTS1) - - -def test_polygon2(): - helper_polygon(POINTS2) - - @pytest.mark.parametrize("mode", ("RGB", "L")) def test_polygon_kite(mode): # Test drawing lines of different gradients (dx>dy, dy>dx) and @@ -682,7 +629,8 @@ def test_polygon_translucent(): assert_image_equal_tofile(im, expected) -def helper_rectangle(bbox): +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +def test_rectangle(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -694,14 +642,6 @@ def helper_rectangle(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle.png") -def test_rectangle1(): - helper_rectangle(BBOX1) - - -def test_rectangle2(): - helper_rectangle(BBOX2) - - def test_big_rectangle(): # Test drawing a rectangle bigger than the image # Arrange @@ -1503,7 +1443,7 @@ def test_discontiguous_corners_polygon(): assert_image_similar_tofile(img, expected, 1) -def test_polygon(): +def test_polygon2(): im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) draw.polygon([(18, 30), (19, 31), (18, 30), (85, 30), (60, 72)], "red") diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index e4e8a38cb..6fc829f1a 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -52,27 +52,19 @@ def test_sanity(): draw.line(list(range(10)), pen) -def helper_ellipse(mode, bbox): +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +def test_ellipse(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) pen = ImageDraw2.Pen("blue", width=2) brush = ImageDraw2.Brush("green") - expected = f"Tests/images/imagedraw_ellipse_{mode}.png" # Act draw.ellipse(bbox, pen, brush) # Assert - assert_image_similar_tofile(im, expected, 1) - - -def test_ellipse1(): - helper_ellipse("RGB", BBOX1) - - -def test_ellipse2(): - helper_ellipse("RGB", BBOX2) + assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_RGB.png", 1) def test_ellipse_edge(): @@ -88,7 +80,8 @@ def test_ellipse_edge(): assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_edge.png", 1) -def helper_line(points): +@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +def test_line(points): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -101,14 +94,6 @@ def helper_line(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") -def test_line1_pen(): - helper_line(POINTS1) - - -def test_line2_pen(): - helper_line(POINTS2) - - def test_line_pen_as_brush(): # Arrange im = Image.new("RGB", (W, H)) @@ -124,7 +109,8 @@ def test_line_pen_as_brush(): assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") -def helper_polygon(points): +@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +def test_polygon(points): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -138,15 +124,8 @@ def helper_polygon(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon.png") -def test_polygon1(): - helper_polygon(POINTS1) - - -def test_polygon2(): - helper_polygon(POINTS2) - - -def helper_rectangle(bbox): +@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +def test_rectangle(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -160,14 +139,6 @@ def helper_rectangle(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle.png") -def test_rectangle1(): - helper_rectangle(BBOX1) - - -def test_rectangle2(): - helper_rectangle(BBOX2) - - def test_big_rectangle(): # Test drawing a rectangle bigger than the image # Arrange diff --git a/Tests/test_imageenhance.py b/Tests/test_imageenhance.py index 8bc94401e..221ef8cdb 100644 --- a/Tests/test_imageenhance.py +++ b/Tests/test_imageenhance.py @@ -1,3 +1,5 @@ +import pytest + from PIL import Image, ImageEnhance from .helper import assert_image_equal, hopper @@ -39,17 +41,17 @@ def _check_alpha(im, original, op, amount): ) -def test_alpha(): +@pytest.mark.parametrize("op", ("Color", "Brightness", "Contrast", "Sharpness")) +def test_alpha(op): # Issue https://github.com/python-pillow/Pillow/issues/899 # Is alpha preserved through image enhancement? original = _half_transparent_image() - for op in ["Color", "Brightness", "Contrast", "Sharpness"]: - for amount in [0, 0.5, 1.0]: - _check_alpha( - getattr(ImageEnhance, op)(original).enhance(amount), - original, - op, - amount, - ) + for amount in [0, 0.5, 1.0]: + _check_alpha( + getattr(ImageEnhance, op)(original).enhance(amount), + original, + op, + amount, + ) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 16da87d46..306a2f1bf 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -28,497 +28,527 @@ TEST_TEXT = "hey you\nyou are awesome\nthis looks awkward" pytestmark = skip_unless_feature("freetype2") -class TestImageFont: - LAYOUT_ENGINE = ImageFont.Layout.BASIC +def test_sanity(): + assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) - def get_font(self): - return ImageFont.truetype( - FONT_PATH, FONT_SIZE, layout_engine=self.LAYOUT_ENGINE - ) - def test_sanity(self): - assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) +@pytest.fixture( + scope="module", + params=[ + pytest.param(ImageFont.Layout.BASIC), + pytest.param(ImageFont.Layout.RAQM, marks=skip_unless_feature("raqm")), + ], +) +def layout_engine(request): + return request.param - def test_font_properties(self): - ttf = self.get_font() - assert ttf.path == FONT_PATH - assert ttf.size == FONT_SIZE - ttf_copy = ttf.font_variant() - assert ttf_copy.path == FONT_PATH - assert ttf_copy.size == FONT_SIZE +@pytest.fixture(scope="module") +def font(layout_engine): + return ImageFont.truetype(FONT_PATH, FONT_SIZE, layout_engine=layout_engine) - ttf_copy = ttf.font_variant(size=FONT_SIZE + 1) - assert ttf_copy.size == FONT_SIZE + 1 - second_font_path = "Tests/fonts/DejaVuSans/DejaVuSans.ttf" - ttf_copy = ttf.font_variant(font=second_font_path) - assert ttf_copy.path == second_font_path +def test_font_properties(font): + assert font.path == FONT_PATH + assert font.size == FONT_SIZE - def test_font_with_name(self): - self.get_font() - self._render(FONT_PATH) + font_copy = font.font_variant() + assert font_copy.path == FONT_PATH + assert font_copy.size == FONT_SIZE - def _font_as_bytes(self): + font_copy = font.font_variant(size=FONT_SIZE + 1) + assert font_copy.size == FONT_SIZE + 1 + + second_font_path = "Tests/fonts/DejaVuSans/DejaVuSans.ttf" + font_copy = font.font_variant(font=second_font_path) + assert font_copy.path == second_font_path + + +def _render(font, layout_engine): + txt = "Hello World!" + ttf = ImageFont.truetype(font, FONT_SIZE, layout_engine=layout_engine) + ttf.getbbox(txt) + + img = Image.new("RGB", (256, 64), "white") + d = ImageDraw.Draw(img) + d.text((10, 10), txt, font=ttf, fill="black") + + return img + + +def test_font_with_name(layout_engine): + _render(FONT_PATH, layout_engine) + + +def test_font_with_filelike(layout_engine): + def _font_as_bytes(): with open(FONT_PATH, "rb") as f: font_bytes = BytesIO(f.read()) return font_bytes - def test_font_with_filelike(self): - ttf = ImageFont.truetype( - self._font_as_bytes(), FONT_SIZE, layout_engine=self.LAYOUT_ENGINE - ) - ttf_copy = ttf.font_variant() - assert ttf_copy.font_bytes == ttf.font_bytes + ttf = ImageFont.truetype(_font_as_bytes(), FONT_SIZE, layout_engine=layout_engine) + ttf_copy = ttf.font_variant() + assert ttf_copy.font_bytes == ttf.font_bytes - self._render(self._font_as_bytes()) - # Usage note: making two fonts from the same buffer fails. - # shared_bytes = self._font_as_bytes() - # self._render(shared_bytes) - # with pytest.raises(Exception): - # _render(shared_bytes) + _render(_font_as_bytes(), layout_engine) + # Usage note: making two fonts from the same buffer fails. + # shared_bytes = _font_as_bytes() + # _render(shared_bytes) + # with pytest.raises(Exception): + # _render(shared_bytes) - def test_font_with_open_file(self): - with open(FONT_PATH, "rb") as f: - self._render(f) - def test_non_ascii_path(self, tmp_path): - tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) - try: - shutil.copy(FONT_PATH, tempfile) - except UnicodeEncodeError: - pytest.skip("Non-ASCII path could not be created") +def test_font_with_open_file(layout_engine): + with open(FONT_PATH, "rb") as f: + _render(f, layout_engine) - ImageFont.truetype(tempfile, FONT_SIZE) - def _render(self, font): - txt = "Hello World!" - ttf = ImageFont.truetype(font, FONT_SIZE, layout_engine=self.LAYOUT_ENGINE) - ttf.getbbox(txt) +def test_render_equal(layout_engine): + img_path = _render(FONT_PATH, layout_engine) + with open(FONT_PATH, "rb") as f: + font_filelike = BytesIO(f.read()) + img_filelike = _render(font_filelike, layout_engine) - img = Image.new("RGB", (256, 64), "white") - d = ImageDraw.Draw(img) - d.text((10, 10), txt, font=ttf, fill="black") + assert_image_equal(img_path, img_filelike) - return img - def test_render_equal(self): - img_path = self._render(FONT_PATH) - with open(FONT_PATH, "rb") as f: - font_filelike = BytesIO(f.read()) - img_filelike = self._render(font_filelike) +def test_non_ascii_path(tmp_path, layout_engine): + tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) + try: + shutil.copy(FONT_PATH, tempfile) + except UnicodeEncodeError: + pytest.skip("Non-ASCII path could not be created") - assert_image_equal(img_path, img_filelike) + ImageFont.truetype(tempfile, FONT_SIZE, layout_engine=layout_engine) - def test_transparent_background(self): - im = Image.new(mode="RGBA", size=(300, 100)) - draw = ImageDraw.Draw(im) - ttf = self.get_font() - txt = "Hello World!" - draw.text((10, 10), txt, font=ttf) +def test_transparent_background(font): + im = Image.new(mode="RGBA", size=(300, 100)) + draw = ImageDraw.Draw(im) - target = "Tests/images/transparent_background_text.png" - assert_image_similar_tofile(im, target, 4.09) + txt = "Hello World!" + draw.text((10, 10), txt, font=font) - target = "Tests/images/transparent_background_text_L.png" - assert_image_similar_tofile(im.convert("L"), target, 0.01) + target = "Tests/images/transparent_background_text.png" + assert_image_similar_tofile(im, target, 4.09) - def test_I16(self): - im = Image.new(mode="I;16", size=(300, 100)) - draw = ImageDraw.Draw(im) - ttf = self.get_font() + target = "Tests/images/transparent_background_text_L.png" + assert_image_similar_tofile(im.convert("L"), target, 0.01) - txt = "Hello World!" - draw.text((10, 10), txt, font=ttf) - target = "Tests/images/transparent_background_text_L.png" - assert_image_similar_tofile(im.convert("L"), target, 0.01) +def test_I16(font): + im = Image.new(mode="I;16", size=(300, 100)) + draw = ImageDraw.Draw(im) - def test_textbbox_equal(self): - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - ttf = self.get_font() + txt = "Hello World!" + draw.text((10, 10), txt, font=font) - txt = "Hello World!" - bbox = draw.textbbox((10, 10), txt, ttf) - draw.text((10, 10), txt, font=ttf) - draw.rectangle(bbox) + target = "Tests/images/transparent_background_text_L.png" + assert_image_similar_tofile(im.convert("L"), target, 0.01) - assert_image_similar_tofile( - im, "Tests/images/rectangle_surrounding_text.png", 2.5 - ) - @pytest.mark.parametrize( - "text, mode, font, size, length_basic, length_raqm", - ( - # basic test - ("text", "L", "FreeMono.ttf", 15, 36, 36), - ("text", "1", "FreeMono.ttf", 15, 36, 36), - # issue 4177 - ("rrr", "L", "DejaVuSans/DejaVuSans.ttf", 18, 21, 22.21875), - ("rrr", "1", "DejaVuSans/DejaVuSans.ttf", 18, 24, 22.21875), - # test 'l' not including extra margin - # using exact value 2047 / 64 for raqm, checked with debugger - ("ill", "L", "OpenSansCondensed-LightItalic.ttf", 63, 33, 31.984375), - ("ill", "1", "OpenSansCondensed-LightItalic.ttf", 63, 33, 31.984375), - ), +def test_textbbox_equal(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + + txt = "Hello World!" + bbox = draw.textbbox((10, 10), txt, font) + draw.text((10, 10), txt, font=font) + draw.rectangle(bbox) + + assert_image_similar_tofile(im, "Tests/images/rectangle_surrounding_text.png", 2.5) + + +@pytest.mark.parametrize( + "text, mode, fontname, size, length_basic, length_raqm", + ( + # basic test + ("text", "L", "FreeMono.ttf", 15, 36, 36), + ("text", "1", "FreeMono.ttf", 15, 36, 36), + # issue 4177 + ("rrr", "L", "DejaVuSans/DejaVuSans.ttf", 18, 21, 22.21875), + ("rrr", "1", "DejaVuSans/DejaVuSans.ttf", 18, 24, 22.21875), + # test 'l' not including extra margin + # using exact value 2047 / 64 for raqm, checked with debugger + ("ill", "L", "OpenSansCondensed-LightItalic.ttf", 63, 33, 31.984375), + ("ill", "1", "OpenSansCondensed-LightItalic.ttf", 63, 33, 31.984375), + ), +) +def test_getlength( + text, mode, fontname, size, layout_engine, length_basic, length_raqm +): + f = ImageFont.truetype("Tests/fonts/" + fontname, size, layout_engine=layout_engine) + + im = Image.new(mode, (1, 1), 0) + d = ImageDraw.Draw(im) + + if layout_engine == ImageFont.Layout.BASIC: + length = d.textlength(text, f) + assert length == length_basic + else: + # disable kerning, kerning metrics changed + length = d.textlength(text, f, features=["-kern"]) + assert length == length_raqm + + +def test_render_multiline(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + line_spacing = font.getbbox("A")[3] + 4 + lines = TEST_TEXT.split("\n") + y = 0 + for line in lines: + draw.text((0, y), line, font=font) + y += line_spacing + + # some versions of freetype have different horizontal spacing. + # setting a tight epsilon, I'm showing the original test failure + # at epsilon = ~38. + assert_image_similar_tofile(im, "Tests/images/multiline_text.png", 6.2) + + +def test_render_multiline_text(font): + # Test that text() correctly connects to multiline_text() + # and that align defaults to left + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + draw.text((0, 0), TEST_TEXT, font=font) + + assert_image_similar_tofile(im, "Tests/images/multiline_text.png", 0.01) + + # Test that text() can pass on additional arguments + # to multiline_text() + draw.text( + (0, 0), TEST_TEXT, fill=None, font=font, anchor=None, spacing=4, align="left" ) - def test_getlength(self, text, mode, font, size, length_basic, length_raqm): - f = ImageFont.truetype( - "Tests/fonts/" + font, size, layout_engine=self.LAYOUT_ENGINE + draw.text((0, 0), TEST_TEXT, None, font, None, 4, "left") + + +@pytest.mark.parametrize( + "align, ext", (("left", ""), ("center", "_center"), ("right", "_right")) +) +def test_render_multiline_text_align(font, align, ext): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + draw.multiline_text((0, 0), TEST_TEXT, font=font, align=align) + + assert_image_similar_tofile(im, f"Tests/images/multiline_text{ext}.png", 0.01) + + +def test_unknown_align(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + + # Act/Assert + with pytest.raises(ValueError): + draw.multiline_text((0, 0), TEST_TEXT, font=font, align="unknown") + + +def test_draw_align(font): + im = Image.new("RGB", (300, 100), "white") + draw = ImageDraw.Draw(im) + line = "some text" + draw.text((100, 40), line, (0, 0, 0), font=font, align="left") + + +def test_multiline_size(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + + with pytest.warns(DeprecationWarning) as log: + # Test that textsize() correctly connects to multiline_textsize() + assert draw.textsize(TEST_TEXT, font=font) == draw.multiline_textsize( + TEST_TEXT, font=font ) - im = Image.new(mode, (1, 1), 0) - d = ImageDraw.Draw(im) - - if self.LAYOUT_ENGINE == ImageFont.Layout.BASIC: - length = d.textlength(text, f) - assert length == length_basic - else: - # disable kerning, kerning metrics changed - length = d.textlength(text, f, features=["-kern"]) - assert length == length_raqm - - def test_render_multiline(self): - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - ttf = self.get_font() - line_spacing = ttf.getbbox("A")[3] + 4 - lines = TEST_TEXT.split("\n") - y = 0 - for line in lines: - draw.text((0, y), line, font=ttf) - y += line_spacing - - # some versions of freetype have different horizontal spacing. - # setting a tight epsilon, I'm showing the original test failure - # at epsilon = ~38. - assert_image_similar_tofile(im, "Tests/images/multiline_text.png", 6.2) - - def test_render_multiline_text(self): - ttf = self.get_font() - - # Test that text() correctly connects to multiline_text() - # and that align defaults to left - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - draw.text((0, 0), TEST_TEXT, font=ttf) - - assert_image_similar_tofile(im, "Tests/images/multiline_text.png", 0.01) - - # Test that text() can pass on additional arguments - # to multiline_text() - draw.text( - (0, 0), TEST_TEXT, fill=None, font=ttf, anchor=None, spacing=4, align="left" - ) - draw.text((0, 0), TEST_TEXT, None, ttf, None, 4, "left") - - # Test align center and right - for align, ext in {"center": "_center", "right": "_right"}.items(): - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - draw.multiline_text((0, 0), TEST_TEXT, font=ttf, align=align) - - assert_image_similar_tofile( - im, "Tests/images/multiline_text" + ext + ".png", 0.01 - ) - - def test_unknown_align(self): - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - ttf = self.get_font() - - # Act/Assert - with pytest.raises(ValueError): - draw.multiline_text((0, 0), TEST_TEXT, font=ttf, align="unknown") - - def test_draw_align(self): - im = Image.new("RGB", (300, 100), "white") - draw = ImageDraw.Draw(im) - ttf = self.get_font() - line = "some text" - draw.text((100, 40), line, (0, 0, 0), font=ttf, align="left") - - def test_multiline_size(self): - ttf = self.get_font() - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - - with pytest.warns(DeprecationWarning) as log: - # Test that textsize() correctly connects to multiline_textsize() - assert draw.textsize(TEST_TEXT, font=ttf) == draw.multiline_textsize( - TEST_TEXT, font=ttf - ) - - # Test that multiline_textsize corresponds to ImageFont.textsize() - # for single line text - assert ttf.getsize("A") == draw.multiline_textsize("A", font=ttf) - - # Test that textsize() can pass on additional arguments - # to multiline_textsize() - draw.textsize(TEST_TEXT, font=ttf, spacing=4) - draw.textsize(TEST_TEXT, ttf, 4) - assert len(log) == 6 - - def test_multiline_bbox(self): - ttf = self.get_font() - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - - # Test that textbbox() correctly connects to multiline_textbbox() - assert draw.textbbox((0, 0), TEST_TEXT, font=ttf) == draw.multiline_textbbox( - (0, 0), TEST_TEXT, font=ttf - ) - - # Test that multiline_textbbox corresponds to ImageFont.textbbox() + # Test that multiline_textsize corresponds to ImageFont.textsize() # for single line text - assert ttf.getbbox("A") == draw.multiline_textbbox((0, 0), "A", font=ttf) + assert font.getsize("A") == draw.multiline_textsize("A", font=font) - # Test that textbbox() can pass on additional arguments - # to multiline_textbbox() - draw.textbbox((0, 0), TEST_TEXT, font=ttf, spacing=4) + # Test that textsize() can pass on additional arguments + # to multiline_textsize() + draw.textsize(TEST_TEXT, font=font, spacing=4) + draw.textsize(TEST_TEXT, font, 4) + assert len(log) == 6 - def test_multiline_width(self): - ttf = self.get_font() - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) +def test_multiline_bbox(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + + # Test that textbbox() correctly connects to multiline_textbbox() + assert draw.textbbox((0, 0), TEST_TEXT, font=font) == draw.multiline_textbbox( + (0, 0), TEST_TEXT, font=font + ) + + # Test that multiline_textbbox corresponds to ImageFont.textbbox() + # for single line text + assert font.getbbox("A") == draw.multiline_textbbox((0, 0), "A", font=font) + + # Test that textbbox() can pass on additional arguments + # to multiline_textbbox() + draw.textbbox((0, 0), TEST_TEXT, font=font, spacing=4) + + +def test_multiline_width(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + + assert ( + draw.textbbox((0, 0), "longest line", font=font)[2] + == draw.multiline_textbbox((0, 0), "longest line\nline", font=font)[2] + ) + with pytest.warns(DeprecationWarning) as log: assert ( - draw.textbbox((0, 0), "longest line", font=ttf)[2] - == draw.multiline_textbbox((0, 0), "longest line\nline", font=ttf)[2] + draw.textsize("longest line", font=font)[0] + == draw.multiline_textsize("longest line\nline", font=font)[0] ) - with pytest.warns(DeprecationWarning) as log: - assert ( - draw.textsize("longest line", font=ttf)[0] - == draw.multiline_textsize("longest line\nline", font=ttf)[0] - ) - assert len(log) == 2 + assert len(log) == 2 - def test_multiline_spacing(self): - ttf = self.get_font() - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - draw.multiline_text((0, 0), TEST_TEXT, font=ttf, spacing=10) +def test_multiline_spacing(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + draw.multiline_text((0, 0), TEST_TEXT, font=font, spacing=10) - assert_image_similar_tofile(im, "Tests/images/multiline_text_spacing.png", 2.5) + assert_image_similar_tofile(im, "Tests/images/multiline_text_spacing.png", 2.5) - def test_rotated_transposed_font(self): - img_grey = Image.new("L", (100, 100)) - draw = ImageDraw.Draw(img_grey) - word = "testing" - font = self.get_font() - orientation = Image.Transpose.ROTATE_90 - transposed_font = ImageFont.TransposedFont(font, orientation=orientation) +@pytest.mark.parametrize( + "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) +) +def test_rotated_transposed_font(font, orientation): + img_grey = Image.new("L", (100, 100)) + draw = ImageDraw.Draw(img_grey) + word = "testing" - # Original font - draw.font = font - with pytest.warns(DeprecationWarning) as log: - box_size_a = draw.textsize(word) - assert box_size_a == font.getsize(word) - assert len(log) == 2 - bbox_a = draw.textbbox((10, 10), word) + transposed_font = ImageFont.TransposedFont(font, orientation=orientation) - # Rotated font - draw.font = transposed_font - with pytest.warns(DeprecationWarning) as log: - box_size_b = draw.textsize(word) - assert box_size_b == transposed_font.getsize(word) - assert len(log) == 2 - bbox_b = draw.textbbox((20, 20), word) + # Original font + draw.font = font + with pytest.warns(DeprecationWarning) as log: + box_size_a = draw.textsize(word) + assert box_size_a == font.getsize(word) + assert len(log) == 2 + bbox_a = draw.textbbox((10, 10), word) - # Check (w,h) of box a is (h,w) of box b - assert box_size_a[0] == box_size_b[1] - assert box_size_a[1] == box_size_b[0] + # Rotated font + draw.font = transposed_font + with pytest.warns(DeprecationWarning) as log: + box_size_b = draw.textsize(word) + assert box_size_b == transposed_font.getsize(word) + assert len(log) == 2 + bbox_b = draw.textbbox((20, 20), word) - # Check bbox b is (20, 20, 20 + h, 20 + w) - assert bbox_b[0] == 20 - assert bbox_b[1] == 20 - assert bbox_b[2] == 20 + bbox_a[3] - bbox_a[1] - assert bbox_b[3] == 20 + bbox_a[2] - bbox_a[0] + # Check (w,h) of box a is (h,w) of box b + assert box_size_a[0] == box_size_b[1] + assert box_size_a[1] == box_size_b[0] - # text length is undefined for vertical text - pytest.raises(ValueError, draw.textlength, word) + # Check bbox b is (20, 20, 20 + h, 20 + w) + assert bbox_b[0] == 20 + assert bbox_b[1] == 20 + assert bbox_b[2] == 20 + bbox_a[3] - bbox_a[1] + assert bbox_b[3] == 20 + bbox_a[2] - bbox_a[0] - def test_unrotated_transposed_font(self): - img_grey = Image.new("L", (100, 100)) - draw = ImageDraw.Draw(img_grey) - word = "testing" - font = self.get_font() + # text length is undefined for vertical text + pytest.raises(ValueError, draw.textlength, word) - orientation = None - transposed_font = ImageFont.TransposedFont(font, orientation=orientation) - # Original font - draw.font = font - with pytest.warns(DeprecationWarning) as log: - box_size_a = draw.textsize(word) - assert len(log) == 1 - bbox_a = draw.textbbox((10, 10), word) - length_a = draw.textlength(word) +@pytest.mark.parametrize( + "orientation", + ( + None, + Image.Transpose.ROTATE_180, + Image.Transpose.FLIP_LEFT_RIGHT, + Image.Transpose.FLIP_TOP_BOTTOM, + ), +) +def test_unrotated_transposed_font(font, orientation): + img_grey = Image.new("L", (100, 100)) + draw = ImageDraw.Draw(img_grey) + word = "testing" - # Rotated font - draw.font = transposed_font - with pytest.warns(DeprecationWarning) as log: - box_size_b = draw.textsize(word) - assert len(log) == 1 - bbox_b = draw.textbbox((20, 20), word) - length_b = draw.textlength(word) + transposed_font = ImageFont.TransposedFont(font, orientation=orientation) - # Check boxes a and b are same size - assert box_size_a == box_size_b + # Original font + draw.font = font + with pytest.warns(DeprecationWarning) as log: + box_size_a = draw.textsize(word) + assert len(log) == 1 + bbox_a = draw.textbbox((10, 10), word) + length_a = draw.textlength(word) - # Check bbox b is (20, 20, 20 + w, 20 + h) - assert bbox_b[0] == 20 - assert bbox_b[1] == 20 - assert bbox_b[2] == 20 + bbox_a[2] - bbox_a[0] - assert bbox_b[3] == 20 + bbox_a[3] - bbox_a[1] + # Rotated font + draw.font = transposed_font + with pytest.warns(DeprecationWarning) as log: + box_size_b = draw.textsize(word) + assert len(log) == 1 + bbox_b = draw.textbbox((20, 20), word) + length_b = draw.textlength(word) - assert length_a == length_b + # Check boxes a and b are same size + assert box_size_a == box_size_b - def test_rotated_transposed_font_get_mask(self): - # Arrange - text = "mask this" - font = self.get_font() - orientation = Image.Transpose.ROTATE_90 - transposed_font = ImageFont.TransposedFont(font, orientation=orientation) + # Check bbox b is (20, 20, 20 + w, 20 + h) + assert bbox_b[0] == 20 + assert bbox_b[1] == 20 + assert bbox_b[2] == 20 + bbox_a[2] - bbox_a[0] + assert bbox_b[3] == 20 + bbox_a[3] - bbox_a[1] - # Act - mask = transposed_font.getmask(text) + assert length_a == length_b - # Assert - assert mask.size == (13, 108) - def test_unrotated_transposed_font_get_mask(self): - # Arrange - text = "mask this" - font = self.get_font() - orientation = None - transposed_font = ImageFont.TransposedFont(font, orientation=orientation) +@pytest.mark.parametrize( + "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) +) +def test_rotated_transposed_font_get_mask(font, orientation): + # Arrange + text = "mask this" + transposed_font = ImageFont.TransposedFont(font, orientation=orientation) - # Act - mask = transposed_font.getmask(text) + # Act + mask = transposed_font.getmask(text) - # Assert - assert mask.size == (108, 13) + # Assert + assert mask.size == (13, 108) - def test_free_type_font_get_name(self): - # Arrange - font = self.get_font() - # Act - name = font.getname() +@pytest.mark.parametrize( + "orientation", + ( + None, + Image.Transpose.ROTATE_180, + Image.Transpose.FLIP_LEFT_RIGHT, + Image.Transpose.FLIP_TOP_BOTTOM, + ), +) +def test_unrotated_transposed_font_get_mask(font, orientation): + # Arrange + text = "mask this" + transposed_font = ImageFont.TransposedFont(font, orientation=orientation) - # Assert - assert ("FreeMono", "Regular") == name + # Act + mask = transposed_font.getmask(text) - def test_free_type_font_get_metrics(self): - # Arrange - font = self.get_font() + # Assert + assert mask.size == (108, 13) - # Act - ascent, descent = font.getmetrics() - # Assert - assert isinstance(ascent, int) - assert isinstance(descent, int) - assert (ascent, descent) == (16, 4) # too exact check? +def test_free_type_font_get_name(font): + assert ("FreeMono", "Regular") == font.getname() - def test_free_type_font_get_offset(self): - # Arrange - font = self.get_font() - text = "offset this" - # Act - with pytest.warns(DeprecationWarning) as log: - offset = font.getoffset(text) +def test_free_type_font_get_metrics(font): + ascent, descent = font.getmetrics() - # Assert - assert len(log) == 1 - assert offset == (0, 3) + assert isinstance(ascent, int) + assert isinstance(descent, int) + assert (ascent, descent) == (16, 4) - def test_free_type_font_get_mask(self): - # Arrange - font = self.get_font() - text = "mask this" - # Act - mask = font.getmask(text) +def test_free_type_font_get_offset(font): + # Arrange + text = "offset this" - # Assert - assert mask.size == (108, 13) + # Act + with pytest.warns(DeprecationWarning) as log: + offset = font.getoffset(text) - def test_load_path_not_found(self): - # Arrange - filename = "somefilenamethatdoesntexist.ttf" + # Assert + assert len(log) == 1 + assert offset == (0, 3) - # Act/Assert + +def test_free_type_font_get_mask(font): + # Arrange + text = "mask this" + + # Act + mask = font.getmask(text) + + # Assert + assert mask.size == (108, 13) + + +def test_load_path_not_found(): + # Arrange + filename = "somefilenamethatdoesntexist.ttf" + + # Act/Assert + with pytest.raises(OSError): + ImageFont.load_path(filename) + with pytest.raises(OSError): + ImageFont.truetype(filename) + + +def test_load_non_font_bytes(): + with open("Tests/images/hopper.jpg", "rb") as f: with pytest.raises(OSError): - ImageFont.load_path(filename) - with pytest.raises(OSError): - ImageFont.truetype(filename) + ImageFont.truetype(f) - def test_load_non_font_bytes(self): - with open("Tests/images/hopper.jpg", "rb") as f: - with pytest.raises(OSError): - ImageFont.truetype(f) - def test_default_font(self): - # Arrange - txt = 'This is a "better than nothing" default font.' - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) +def test_default_font(): + # Arrange + txt = 'This is a "better than nothing" default font.' + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) - # Act - default_font = ImageFont.load_default() - draw.text((10, 10), txt, font=default_font) + # Act + default_font = ImageFont.load_default() + draw.text((10, 10), txt, font=default_font) - # Assert - assert_image_equal_tofile(im, "Tests/images/default_font.png") + # Assert + assert_image_equal_tofile(im, "Tests/images/default_font.png") - def test_getbbox_empty(self): - # issue #2614 - font = self.get_font() - # should not crash. - assert (0, 0, 0, 0) == font.getbbox("") - def test_render_empty(self): - # issue 2666 - font = self.get_font() - im = Image.new(mode="RGB", size=(300, 100)) - target = im.copy() - draw = ImageDraw.Draw(im) - # should not crash here. - draw.text((10, 10), "", font=font) - assert_image_equal(im, target) +def test_getbbox_empty(font): + # issue #2614, should not crash. + assert (0, 0, 0, 0) == font.getbbox("") - def test_unicode_pilfont(self): - # should not segfault, should return UnicodeDecodeError - # issue #2826 - font = ImageFont.load_default() - with pytest.raises(UnicodeEncodeError): - font.getbbox("’") - def test_unicode_extended(self): - # issue #3777 - text = "A\u278A\U0001F12B" - target = "Tests/images/unicode_extended.png" +def test_render_empty(font): + # issue 2666 + im = Image.new(mode="RGB", size=(300, 100)) + target = im.copy() + draw = ImageDraw.Draw(im) + # should not crash here. + draw.text((10, 10), "", font=font) + assert_image_equal(im, target) - ttf = ImageFont.truetype( - "Tests/fonts/NotoSansSymbols-Regular.ttf", - FONT_SIZE, - layout_engine=self.LAYOUT_ENGINE, - ) - img = Image.new("RGB", (100, 60)) - d = ImageDraw.Draw(img) - d.text((10, 10), text, font=ttf) - # fails with 14.7 - assert_image_similar_tofile(img, target, 6.2) +def test_unicode_pilfont(): + # should not segfault, should return UnicodeDecodeError + # issue #2826 + font = ImageFont.load_default() + with pytest.raises(UnicodeEncodeError): + font.getbbox("’") - def _test_fake_loading_font(self, monkeypatch, path_to_fake, fontname): + +def test_unicode_extended(layout_engine): + # issue #3777 + text = "A\u278A\U0001F12B" + target = "Tests/images/unicode_extended.png" + + ttf = ImageFont.truetype( + "Tests/fonts/NotoSansSymbols-Regular.ttf", + FONT_SIZE, + layout_engine=layout_engine, + ) + img = Image.new("RGB", (100, 60)) + d = ImageDraw.Draw(img) + d.text((10, 10), text, font=ttf) + + # fails with 14.7 + assert_image_similar_tofile(img, target, 6.2) + + +@pytest.mark.parametrize( + "platform, font_directory", + (("linux", "/usr/local/share/fonts"), ("darwin", "/System/Library/Fonts")), +) +@pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") +def test_find_font(monkeypatch, platform, font_directory): + def _test_fake_loading_font(path_to_fake, fontname): # Make a copy of FreeTypeFont so we can patch the original free_type_font = copy.deepcopy(ImageFont.FreeTypeFont) with monkeypatch.context() as m: @@ -539,543 +569,527 @@ class TestImageFont: name = font.getname() assert ("FreeMono", "Regular") == name - @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - def test_find_linux_font(self, monkeypatch): - # A lot of mocking here - this is more for hitting code and - # catching syntax like errors - font_directory = "/usr/local/share/fonts" - monkeypatch.setattr(sys, "platform", "linux") + # A lot of mocking here - this is more for hitting code and + # catching syntax like errors + monkeypatch.setattr(sys, "platform", platform) + if platform == "linux": monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") - def fake_walker(path): - if path == font_directory: - return [ - ( - path, - [], - ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], - ) - ] - return [(path, [], ["some_random_font.ttf"])] - - monkeypatch.setattr(os, "walk", fake_walker) - # Test that the font loads both with and without the - # extension - self._test_fake_loading_font( - monkeypatch, font_directory + "/Arial.ttf", "Arial.ttf" - ) - self._test_fake_loading_font( - monkeypatch, font_directory + "/Arial.ttf", "Arial" - ) - - # Test that non-ttf fonts can be found without the - # extension - self._test_fake_loading_font( - monkeypatch, font_directory + "/Single.otf", "Single" - ) - - # Test that ttf fonts are preferred if the extension is - # not specified - self._test_fake_loading_font( - monkeypatch, font_directory + "/Duplicate.ttf", "Duplicate" - ) - - @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - def test_find_macos_font(self, monkeypatch): - # Like the linux test, more cover hitting code rather than testing - # correctness. - font_directory = "/System/Library/Fonts" - monkeypatch.setattr(sys, "platform", "darwin") - - def fake_walker(path): - if path == font_directory: - return [ - ( - path, - [], - ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], - ) - ] - return [(path, [], ["some_random_font.ttf"])] - - monkeypatch.setattr(os, "walk", fake_walker) - self._test_fake_loading_font( - monkeypatch, font_directory + "/Arial.ttf", "Arial.ttf" - ) - self._test_fake_loading_font( - monkeypatch, font_directory + "/Arial.ttf", "Arial" - ) - self._test_fake_loading_font( - monkeypatch, font_directory + "/Single.otf", "Single" - ) - self._test_fake_loading_font( - monkeypatch, font_directory + "/Duplicate.ttf", "Duplicate" - ) - - def test_imagefont_getters(self): - # Arrange - t = self.get_font() - - # Act / Assert - assert t.getmetrics() == (16, 4) - assert t.font.ascent == 16 - assert t.font.descent == 4 - assert t.font.height == 20 - assert t.font.x_ppem == 20 - assert t.font.y_ppem == 20 - assert t.font.glyphs == 4177 - assert t.getbbox("A") == (0, 4, 12, 16) - assert t.getbbox("AB") == (0, 4, 24, 16) - assert t.getbbox("M") == (0, 4, 12, 16) - assert t.getbbox("y") == (0, 7, 12, 20) - assert t.getbbox("a") == (0, 7, 12, 16) - assert t.getlength("A") == 12 - assert t.getlength("AB") == 24 - assert t.getlength("M") == 12 - assert t.getlength("y") == 12 - assert t.getlength("a") == 12 - with pytest.warns(DeprecationWarning) as log: - assert t.getsize("A") == (12, 16) - assert t.getsize("AB") == (24, 16) - assert t.getsize("M") == (12, 16) - assert t.getsize("y") == (12, 20) - assert t.getsize("a") == (12, 16) - assert t.getsize_multiline("A") == (12, 16) - assert t.getsize_multiline("AB") == (24, 16) - assert t.getsize_multiline("a") == (12, 16) - assert t.getsize_multiline("ABC\n") == (36, 36) - assert t.getsize_multiline("ABC\nA") == (36, 36) - assert t.getsize_multiline("ABC\nAaaa") == (48, 36) - assert len(log) == 11 - - def test_getsize_stroke(self): - # Arrange - t = self.get_font() - - # Act / Assert - for stroke_width in [0, 2]: - assert t.getbbox("A", stroke_width=stroke_width) == ( - 0 - stroke_width, - 4 - stroke_width, - 12 + stroke_width, - 16 + stroke_width, - ) - with pytest.warns(DeprecationWarning) as log: - assert t.getsize("A", stroke_width=stroke_width) == ( - 12 + stroke_width * 2, - 16 + stroke_width * 2, + def fake_walker(path): + if path == font_directory: + return [ + ( + path, + [], + ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], ) - assert t.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width) == ( - 48 + stroke_width * 2, - 36 + stroke_width * 4, - ) - assert len(log) == 2 + ] + return [(path, [], ["some_random_font.ttf"])] - def test_complex_font_settings(self): - # Arrange - t = self.get_font() - # Act / Assert - if t.layout_engine == ImageFont.Layout.BASIC: - with pytest.raises(KeyError): - t.getmask("абвг", direction="rtl") - with pytest.raises(KeyError): - t.getmask("абвг", features=["-kern"]) - with pytest.raises(KeyError): - t.getmask("абвг", language="sr") + monkeypatch.setattr(os, "walk", fake_walker) - def test_variation_get(self): - font = self.get_font() + # Test that the font loads both with and without the extension + _test_fake_loading_font(font_directory + "/Arial.ttf", "Arial.ttf") + _test_fake_loading_font(font_directory + "/Arial.ttf", "Arial") - freetype = parse_version(features.version_module("freetype2")) - if freetype < parse_version("2.9.1"): - with pytest.raises(NotImplementedError): - font.get_variation_names() - with pytest.raises(NotImplementedError): - font.get_variation_axes() - return + # Test that non-ttf fonts can be found without the extension + _test_fake_loading_font(font_directory + "/Single.otf", "Single") - with pytest.raises(OSError): + # Test that ttf fonts are preferred if the extension is not specified + _test_fake_loading_font(font_directory + "/Duplicate.ttf", "Duplicate") + + +def test_imagefont_getters(font): + assert font.getmetrics() == (16, 4) + assert font.font.ascent == 16 + assert font.font.descent == 4 + assert font.font.height == 20 + assert font.font.x_ppem == 20 + assert font.font.y_ppem == 20 + assert font.font.glyphs == 4177 + assert font.getbbox("A") == (0, 4, 12, 16) + assert font.getbbox("AB") == (0, 4, 24, 16) + assert font.getbbox("M") == (0, 4, 12, 16) + assert font.getbbox("y") == (0, 7, 12, 20) + assert font.getbbox("a") == (0, 7, 12, 16) + assert font.getlength("A") == 12 + assert font.getlength("AB") == 24 + assert font.getlength("M") == 12 + assert font.getlength("y") == 12 + assert font.getlength("a") == 12 + with pytest.warns(DeprecationWarning) as log: + assert font.getsize("A") == (12, 16) + assert font.getsize("AB") == (24, 16) + assert font.getsize("M") == (12, 16) + assert font.getsize("y") == (12, 20) + assert font.getsize("a") == (12, 16) + assert font.getsize_multiline("A") == (12, 16) + assert font.getsize_multiline("AB") == (24, 16) + assert font.getsize_multiline("a") == (12, 16) + assert font.getsize_multiline("ABC\n") == (36, 36) + assert font.getsize_multiline("ABC\nA") == (36, 36) + assert font.getsize_multiline("ABC\nAaaa") == (48, 36) + assert len(log) == 11 + + +@pytest.mark.parametrize("stroke_width", (0, 2)) +def test_getsize_stroke(font, stroke_width): + assert font.getbbox("A", stroke_width=stroke_width) == ( + 0 - stroke_width, + 4 - stroke_width, + 12 + stroke_width, + 16 + stroke_width, + ) + with pytest.warns(DeprecationWarning) as log: + assert font.getsize("A", stroke_width=stroke_width) == ( + 12 + stroke_width * 2, + 16 + stroke_width * 2, + ) + assert font.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width) == ( + 48 + stroke_width * 2, + 36 + stroke_width * 4, + ) + assert len(log) == 2 + + +def test_complex_font_settings(): + t = ImageFont.truetype(FONT_PATH, FONT_SIZE, layout_engine=ImageFont.Layout.BASIC) + with pytest.raises(KeyError): + t.getmask("абвг", direction="rtl") + with pytest.raises(KeyError): + t.getmask("абвг", features=["-kern"]) + with pytest.raises(KeyError): + t.getmask("абвг", language="sr") + + +def test_variation_get(font): + freetype = parse_version(features.version_module("freetype2")) + if freetype < parse_version("2.9.1"): + with pytest.raises(NotImplementedError): font.get_variation_names() - with pytest.raises(OSError): + with pytest.raises(NotImplementedError): font.get_variation_axes() + return - font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf") - assert font.get_variation_names(), [ - b"ExtraLight", - b"Light", - b"Regular", - b"Semibold", - b"Bold", - b"Black", - b"Black Medium Contrast", - b"Black High Contrast", - b"Default", - ] - assert font.get_variation_axes() == [ - {"name": b"Weight", "minimum": 200, "maximum": 900, "default": 389}, - {"name": b"Contrast", "minimum": 0, "maximum": 100, "default": 0}, - ] + with pytest.raises(OSError): + font.get_variation_names() + with pytest.raises(OSError): + font.get_variation_axes() - font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf") - assert font.get_variation_names() == [ - b"20", - b"40", - b"60", - b"80", - b"100", - b"120", - b"140", - b"160", - b"180", - b"200", - b"220", - b"240", - b"260", - b"280", - b"300", - b"Regular", - ] - assert font.get_variation_axes() == [ - {"name": b"Size", "minimum": 0, "maximum": 300, "default": 0} - ] + font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf") + assert font.get_variation_names(), [ + b"ExtraLight", + b"Light", + b"Regular", + b"Semibold", + b"Bold", + b"Black", + b"Black Medium Contrast", + b"Black High Contrast", + b"Default", + ] + assert font.get_variation_axes() == [ + {"name": b"Weight", "minimum": 200, "maximum": 900, "default": 389}, + {"name": b"Contrast", "minimum": 0, "maximum": 100, "default": 0}, + ] - def _check_text(self, font, path, epsilon): - im = Image.new("RGB", (100, 75), "white") - d = ImageDraw.Draw(im) - d.text((10, 10), "Text", font=font, fill="black") + font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf") + assert font.get_variation_names() == [ + b"20", + b"40", + b"60", + b"80", + b"100", + b"120", + b"140", + b"160", + b"180", + b"200", + b"220", + b"240", + b"260", + b"280", + b"300", + b"Regular", + ] + assert font.get_variation_axes() == [ + {"name": b"Size", "minimum": 0, "maximum": 300, "default": 0} + ] - try: + +def _check_text(font, path, epsilon): + im = Image.new("RGB", (100, 75), "white") + d = ImageDraw.Draw(im) + d.text((10, 10), "Text", font=font, fill="black") + + try: + assert_image_similar_tofile(im, path, epsilon) + except AssertionError: + if "_adobe" in path: + path = path.replace("_adobe", "_adobe_older_harfbuzz") assert_image_similar_tofile(im, path, epsilon) - except AssertionError: - if "_adobe" in path: - path = path.replace("_adobe", "_adobe_older_harfbuzz") - assert_image_similar_tofile(im, path, epsilon) - else: - raise - - def test_variation_set_by_name(self): - font = self.get_font() - - freetype = parse_version(features.version_module("freetype2")) - if freetype < parse_version("2.9.1"): - with pytest.raises(NotImplementedError): - font.set_variation_by_name("Bold") - return - - with pytest.raises(OSError): - font.set_variation_by_name("Bold") - - font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) - self._check_text(font, "Tests/images/variation_adobe.png", 11) - for name in ["Bold", b"Bold"]: - font.set_variation_by_name(name) - self._check_text(font, "Tests/images/variation_adobe_name.png", 11) - - font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) - self._check_text(font, "Tests/images/variation_tiny.png", 40) - for name in ["200", b"200"]: - font.set_variation_by_name(name) - self._check_text(font, "Tests/images/variation_tiny_name.png", 40) - - def test_variation_set_by_axes(self): - font = self.get_font() - - freetype = parse_version(features.version_module("freetype2")) - if freetype < parse_version("2.9.1"): - with pytest.raises(NotImplementedError): - font.set_variation_by_axes([100]) - return - - with pytest.raises(OSError): - font.set_variation_by_axes([500, 50]) - - font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) - font.set_variation_by_axes([500, 50]) - self._check_text(font, "Tests/images/variation_adobe_axes.png", 11.05) - - font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) - font.set_variation_by_axes([100]) - self._check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) - - def test_textbbox_non_freetypefont(self): - im = Image.new("RGB", (200, 200)) - d = ImageDraw.Draw(im) - default_font = ImageFont.load_default() - with pytest.warns(DeprecationWarning) as log: - width, height = d.textsize("test", font=default_font) - assert len(log) == 1 - assert d.textlength("test", font=default_font) == width - assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, width, height) - - @pytest.mark.parametrize( - "anchor, left, top", - ( - # test horizontal anchors - ("ls", 0, -36), - ("ms", -64, -36), - ("rs", -128, -36), - # test vertical anchors - ("ma", -64, 16), - ("mt", -64, 0), - ("mm", -64, -17), - ("mb", -64, -44), - ("md", -64, -51), - ), - ids=("ls", "ms", "rs", "ma", "mt", "mm", "mb", "md"), - ) - def test_anchor(self, anchor, left, top): - name, text = "quick", "Quick" - path = f"Tests/images/test_anchor_{name}_{anchor}.png" - - if self.LAYOUT_ENGINE == ImageFont.Layout.RAQM: - width, height = (129, 44) else: - width, height = (128, 44) + raise - bbox_expected = (left, top, left + width, top + height) - f = ImageFont.truetype( - "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=self.LAYOUT_ENGINE - ) +def test_variation_set_by_name(font): + freetype = parse_version(features.version_module("freetype2")) + if freetype < parse_version("2.9.1"): + with pytest.raises(NotImplementedError): + font.set_variation_by_name("Bold") + return - im = Image.new("RGB", (200, 200), "white") - d = ImageDraw.Draw(im) - d.line(((0, 100), (200, 100)), "gray") - d.line(((100, 0), (100, 200)), "gray") - d.text((100, 100), text, fill="black", anchor=anchor, font=f) + with pytest.raises(OSError): + font.set_variation_by_name("Bold") - assert d.textbbox((0, 0), text, f, anchor=anchor) == bbox_expected + font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) + _check_text(font, "Tests/images/variation_adobe.png", 11) + for name in ["Bold", b"Bold"]: + font.set_variation_by_name(name) + assert font.getname()[1] == "Bold" + _check_text(font, "Tests/images/variation_adobe_name.png", 16) - assert_image_similar_tofile(im, path, 7) + font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) + _check_text(font, "Tests/images/variation_tiny.png", 40) + for name in ["200", b"200"]: + font.set_variation_by_name(name) + assert font.getname()[1] == "200" + _check_text(font, "Tests/images/variation_tiny_name.png", 40) - @pytest.mark.parametrize( - "anchor, align", - ( - # test horizontal anchors - ("lm", "left"), - ("lm", "center"), - ("lm", "right"), - ("mm", "left"), - ("mm", "center"), - ("mm", "right"), - ("rm", "left"), - ("rm", "center"), - ("rm", "right"), - # test vertical anchors - ("ma", "center"), - # ("mm", "center"), # duplicate - ("md", "center"), - ), + +def test_variation_set_by_axes(font): + freetype = parse_version(features.version_module("freetype2")) + if freetype < parse_version("2.9.1"): + with pytest.raises(NotImplementedError): + font.set_variation_by_axes([100]) + return + + with pytest.raises(OSError): + font.set_variation_by_axes([500, 50]) + + font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) + font.set_variation_by_axes([500, 50]) + _check_text(font, "Tests/images/variation_adobe_axes.png", 11.05) + + font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) + font.set_variation_by_axes([100]) + _check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) + + +def test_textbbox_non_freetypefont(): + im = Image.new("RGB", (200, 200)) + d = ImageDraw.Draw(im) + default_font = ImageFont.load_default() + with pytest.warns(DeprecationWarning) as log: + width, height = d.textsize("test", font=default_font) + assert len(log) == 1 + assert d.textlength("test", font=default_font) == width + assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, width, height) + + +@pytest.mark.parametrize( + "anchor, left, top", + ( + # test horizontal anchors + ("ls", 0, -36), + ("ms", -64, -36), + ("rs", -128, -36), + # test vertical anchors + ("ma", -64, 16), + ("mt", -64, 0), + ("mm", -64, -17), + ("mb", -64, -44), + ("md", -64, -51), + ), + ids=("ls", "ms", "rs", "ma", "mt", "mm", "mb", "md"), +) +def test_anchor(layout_engine, anchor, left, top): + name, text = "quick", "Quick" + path = f"Tests/images/test_anchor_{name}_{anchor}.png" + + if layout_engine == ImageFont.Layout.RAQM: + width, height = (129, 44) + else: + width, height = (128, 44) + + bbox_expected = (left, top, left + width, top + height) + + f = ImageFont.truetype( + "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=layout_engine ) - def test_anchor_multiline(self, anchor, align): - target = f"Tests/images/test_anchor_multiline_{anchor}_{align}.png" - text = "a\nlong\ntext sample" - f = ImageFont.truetype( - "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=self.LAYOUT_ENGINE + im = Image.new("RGB", (200, 200), "white") + d = ImageDraw.Draw(im) + d.line(((0, 100), (200, 100)), "gray") + d.line(((100, 0), (100, 200)), "gray") + d.text((100, 100), text, fill="black", anchor=anchor, font=f) + + assert d.textbbox((0, 0), text, f, anchor=anchor) == bbox_expected + + assert_image_similar_tofile(im, path, 7) + + +@pytest.mark.parametrize( + "anchor, align", + ( + # test horizontal anchors + ("lm", "left"), + ("lm", "center"), + ("lm", "right"), + ("mm", "left"), + ("mm", "center"), + ("mm", "right"), + ("rm", "left"), + ("rm", "center"), + ("rm", "right"), + # test vertical anchors + ("ma", "center"), + # ("mm", "center"), # duplicate + ("md", "center"), + ), +) +def test_anchor_multiline(layout_engine, anchor, align): + target = f"Tests/images/test_anchor_multiline_{anchor}_{align}.png" + text = "a\nlong\ntext sample" + + f = ImageFont.truetype( + "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=layout_engine + ) + + # test render + im = Image.new("RGB", (600, 400), "white") + d = ImageDraw.Draw(im) + d.line(((0, 200), (600, 200)), "gray") + d.line(((300, 0), (300, 400)), "gray") + d.multiline_text((300, 200), text, fill="black", anchor=anchor, font=f, align=align) + + assert_image_similar_tofile(im, target, 4) + + +def test_anchor_invalid(font): + im = Image.new("RGB", (100, 100), "white") + d = ImageDraw.Draw(im) + d.font = font + + for anchor in ["", "l", "a", "lax", "sa", "xa", "lx"]: + pytest.raises(ValueError, lambda: font.getmask2("hello", anchor=anchor)) + pytest.raises(ValueError, lambda: font.getbbox("hello", anchor=anchor)) + pytest.raises(ValueError, lambda: d.text((0, 0), "hello", anchor=anchor)) + pytest.raises(ValueError, lambda: d.textbbox((0, 0), "hello", anchor=anchor)) + pytest.raises( + ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) + ) + pytest.raises( + ValueError, + lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), + ) + for anchor in ["lt", "lb"]: + pytest.raises( + ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) + ) + pytest.raises( + ValueError, + lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), ) - # test render - im = Image.new("RGB", (600, 400), "white") - d = ImageDraw.Draw(im) - d.line(((0, 200), (600, 200)), "gray") - d.line(((300, 0), (300, 400)), "gray") - d.multiline_text( - (300, 200), text, fill="black", anchor=anchor, font=f, align=align - ) - assert_image_similar_tofile(im, target, 4) +@pytest.mark.parametrize("bpp", (1, 2, 4, 8)) +def test_bitmap_font(layout_engine, bpp): + text = "Bitmap Font" + layout_name = ["basic", "raqm"][layout_engine] + target = f"Tests/images/bitmap_font_{bpp}_{layout_name}.png" + font = ImageFont.truetype( + f"Tests/fonts/DejaVuSans/DejaVuSans-24-{bpp}-stripped.ttf", + 24, + layout_engine=layout_engine, + ) - def test_anchor_invalid(self): - font = self.get_font() - im = Image.new("RGB", (100, 100), "white") - d = ImageDraw.Draw(im) - d.font = font + im = Image.new("RGB", (160, 35), "white") + draw = ImageDraw.Draw(im) + draw.text((2, 2), text, "black", font) - for anchor in ["", "l", "a", "lax", "sa", "xa", "lx"]: - pytest.raises(ValueError, lambda: font.getmask2("hello", anchor=anchor)) - pytest.raises(ValueError, lambda: font.getbbox("hello", anchor=anchor)) - pytest.raises(ValueError, lambda: d.text((0, 0), "hello", anchor=anchor)) - pytest.raises( - ValueError, lambda: d.textbbox((0, 0), "hello", anchor=anchor) - ) - pytest.raises( - ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) - ) - pytest.raises( - ValueError, - lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), - ) - for anchor in ["lt", "lb"]: - pytest.raises( - ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) - ) - pytest.raises( - ValueError, - lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), - ) + assert_image_equal_tofile(im, target) - @skip_unless_feature("freetype2") - @pytest.mark.parametrize("bpp", (1, 2, 4, 8)) - def test_bitmap_font(self, bpp): - text = "Bitmap Font" - layout_name = ["basic", "raqm"][self.LAYOUT_ENGINE] - target = f"Tests/images/bitmap_font_{bpp}_{layout_name}.png" - font = ImageFont.truetype( - f"Tests/fonts/DejaVuSans/DejaVuSans-24-{bpp}-stripped.ttf", - 24, - layout_engine=self.LAYOUT_ENGINE, - ) - im = Image.new("RGB", (160, 35), "white") - draw = ImageDraw.Draw(im) - draw.text((2, 2), text, "black", font) +def test_bitmap_font_stroke(layout_engine): + text = "Bitmap Font" + layout_name = ["basic", "raqm"][layout_engine] + target = f"Tests/images/bitmap_font_stroke_{layout_name}.png" + font = ImageFont.truetype( + "Tests/fonts/DejaVuSans/DejaVuSans-24-8-stripped.ttf", + 24, + layout_engine=layout_engine, + ) - assert_image_equal_tofile(im, target) + im = Image.new("RGB", (160, 35), "white") + draw = ImageDraw.Draw(im) + draw.text((2, 2), text, "black", font, stroke_width=2, stroke_fill="red") - def test_bitmap_font_stroke(self): - text = "Bitmap Font" - layout_name = ["basic", "raqm"][self.LAYOUT_ENGINE] - target = f"Tests/images/bitmap_font_stroke_{layout_name}.png" - font = ImageFont.truetype( - "Tests/fonts/DejaVuSans/DejaVuSans-24-8-stripped.ttf", - 24, - layout_engine=self.LAYOUT_ENGINE, - ) + assert_image_similar_tofile(im, target, 0.03) - im = Image.new("RGB", (160, 35), "white") - draw = ImageDraw.Draw(im) - draw.text((2, 2), text, "black", font, stroke_width=2, stroke_fill="red") - assert_image_similar_tofile(im, target, 0.03) +def test_standard_embedded_color(layout_engine): + txt = "Hello World!" + ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) + ttf.getbbox(txt) - def test_standard_embedded_color(self): - txt = "Hello World!" - ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=self.LAYOUT_ENGINE) - ttf.getbbox(txt) + im = Image.new("RGB", (300, 64), "white") + d = ImageDraw.Draw(im) + d.text((10, 10), txt, font=ttf, fill="#fa6", embedded_color=True) - im = Image.new("RGB", (300, 64), "white") - d = ImageDraw.Draw(im) - d.text((10, 10), txt, font=ttf, fill="#fa6", embedded_color=True) + assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1) - assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 6.2) - def test_cbdt(self): - try: - font = ImageFont.truetype( - "Tests/fonts/NotoColorEmoji.ttf", - size=109, - layout_engine=self.LAYOUT_ENGINE, - ) +@pytest.mark.parametrize("fontmode", ("1", "L", "RGBA")) +def test_float_coord(layout_engine, fontmode): + txt = "Hello World!" + ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) - im = Image.new("RGB", (150, 150), "white") - d = ImageDraw.Draw(im) - - d.text((10, 10), "\U0001f469", font=font, embedded_color=True) - - assert_image_similar_tofile(im, "Tests/images/cbdt_notocoloremoji.png", 6.2) - except OSError as e: # pragma: no cover - assert str(e) in ("unimplemented feature", "unknown file format") - pytest.skip("freetype compiled without libpng or CBDT support") - - def test_cbdt_mask(self): - try: - font = ImageFont.truetype( - "Tests/fonts/NotoColorEmoji.ttf", - size=109, - layout_engine=self.LAYOUT_ENGINE, - ) - - im = Image.new("RGB", (150, 150), "white") - d = ImageDraw.Draw(im) - - d.text((10, 10), "\U0001f469", "black", font=font) + im = Image.new("RGB", (300, 64), "white") + d = ImageDraw.Draw(im) + if fontmode == "1": + d.fontmode = "1" + embedded_color = fontmode == "RGBA" + d.text((9.5, 9.5), txt, font=ttf, fill="#fa6", embedded_color=embedded_color) + try: + assert_image_similar_tofile(im, "Tests/images/text_float_coord.png", 3.9) + except AssertionError: + if fontmode == "1" and layout_engine == ImageFont.Layout.BASIC: assert_image_similar_tofile( - im, "Tests/images/cbdt_notocoloremoji_mask.png", 6.2 + im, "Tests/images/text_float_coord_1_alt.png", 1 ) - except OSError as e: # pragma: no cover - assert str(e) in ("unimplemented feature", "unknown file format") - pytest.skip("freetype compiled without libpng or CBDT support") + else: + raise - def test_sbix(self): - try: - font = ImageFont.truetype( - "Tests/fonts/chromacheck-sbix.woff", - size=300, - layout_engine=self.LAYOUT_ENGINE, - ) - im = Image.new("RGB", (400, 400), "white") - d = ImageDraw.Draw(im) - - d.text((50, 50), "\uE901", font=font, embedded_color=True) - - assert_image_similar_tofile(im, "Tests/images/chromacheck-sbix.png", 1) - except OSError as e: # pragma: no cover - assert str(e) in ("unimplemented feature", "unknown file format") - pytest.skip("freetype compiled without libpng or SBIX support") - - def test_sbix_mask(self): - try: - font = ImageFont.truetype( - "Tests/fonts/chromacheck-sbix.woff", - size=300, - layout_engine=self.LAYOUT_ENGINE, - ) - - im = Image.new("RGB", (400, 400), "white") - d = ImageDraw.Draw(im) - - d.text((50, 50), "\uE901", (100, 0, 0), font=font) - - assert_image_similar_tofile(im, "Tests/images/chromacheck-sbix_mask.png", 1) - except OSError as e: # pragma: no cover - assert str(e) in ("unimplemented feature", "unknown file format") - pytest.skip("freetype compiled without libpng or SBIX support") - - @skip_unless_feature_version("freetype2", "2.10.0") - def test_colr(self): +def test_cbdt(layout_engine): + try: font = ImageFont.truetype( - "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", - size=64, - layout_engine=self.LAYOUT_ENGINE, + "Tests/fonts/NotoColorEmoji.ttf", size=109, layout_engine=layout_engine ) - im = Image.new("RGB", (300, 75), "white") + im = Image.new("RGB", (150, 150), "white") d = ImageDraw.Draw(im) - d.text((15, 5), "Bungee", font=font, embedded_color=True) + d.text((10, 10), "\U0001f469", font=font, embedded_color=True) - assert_image_similar_tofile(im, "Tests/images/colr_bungee.png", 21) + assert_image_similar_tofile(im, "Tests/images/cbdt_notocoloremoji.png", 6.2) + except OSError as e: # pragma: no cover + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("freetype compiled without libpng or CBDT support") - @skip_unless_feature_version("freetype2", "2.10.0") - def test_colr_mask(self): + +def test_cbdt_mask(layout_engine): + try: font = ImageFont.truetype( - "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", - size=64, - layout_engine=self.LAYOUT_ENGINE, + "Tests/fonts/NotoColorEmoji.ttf", size=109, layout_engine=layout_engine ) - im = Image.new("RGB", (300, 75), "white") + im = Image.new("RGB", (150, 150), "white") d = ImageDraw.Draw(im) - d.text((15, 5), "Bungee", "black", font=font) + d.text((10, 10), "\U0001f469", "black", font=font) - assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 22) - - def test_fill_deprecation(self): - font = self.get_font() - with pytest.warns(DeprecationWarning): - font.getmask2("Hello world", fill=Image.core.fill) - with pytest.warns(DeprecationWarning): - with pytest.raises(TypeError): - font.getmask2("Hello world", fill=None) + assert_image_similar_tofile( + im, "Tests/images/cbdt_notocoloremoji_mask.png", 6.2 + ) + except OSError as e: # pragma: no cover + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("freetype compiled without libpng or CBDT support") -@skip_unless_feature("raqm") -class TestImageFont_RaqmLayout(TestImageFont): - LAYOUT_ENGINE = ImageFont.Layout.RAQM +def test_sbix(layout_engine): + try: + font = ImageFont.truetype( + "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine + ) + + im = Image.new("RGB", (400, 400), "white") + d = ImageDraw.Draw(im) + + d.text((50, 50), "\uE901", font=font, embedded_color=True) + + assert_image_similar_tofile(im, "Tests/images/chromacheck-sbix.png", 1) + except OSError as e: # pragma: no cover + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("freetype compiled without libpng or SBIX support") + + +def test_sbix_mask(layout_engine): + try: + font = ImageFont.truetype( + "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine + ) + + im = Image.new("RGB", (400, 400), "white") + d = ImageDraw.Draw(im) + + d.text((50, 50), "\uE901", (100, 0, 0), font=font) + + assert_image_similar_tofile(im, "Tests/images/chromacheck-sbix_mask.png", 1) + except OSError as e: # pragma: no cover + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("freetype compiled without libpng or SBIX support") + + +@skip_unless_feature_version("freetype2", "2.10.0") +def test_colr(layout_engine): + font = ImageFont.truetype( + "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", + size=64, + layout_engine=layout_engine, + ) + + im = Image.new("RGB", (300, 75), "white") + d = ImageDraw.Draw(im) + + d.text((15, 5), "Bungee", font=font, embedded_color=True) + + assert_image_similar_tofile(im, "Tests/images/colr_bungee.png", 21) + + +@skip_unless_feature_version("freetype2", "2.10.0") +def test_colr_mask(layout_engine): + font = ImageFont.truetype( + "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", + size=64, + layout_engine=layout_engine, + ) + + im = Image.new("RGB", (300, 75), "white") + d = ImageDraw.Draw(im) + + d.text((15, 5), "Bungee", "black", font=font) + + assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 22) + + +def test_woff2(layout_engine): + try: + font = ImageFont.truetype( + "Tests/fonts/OpenSans.woff2", + size=64, + layout_engine=layout_engine, + ) + except OSError as e: + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("FreeType compiled without brotli or WOFF2 support") + + im = Image.new("RGB", (350, 100), "white") + d = ImageDraw.Draw(im) + + d.text((15, 5), "OpenSans", "black", font=font) + + assert_image_similar_tofile(im, "Tests/images/test_woff2.png", 5) + + +def test_fill_deprecation(font): + with pytest.warns(DeprecationWarning): + font.getmask2("Hello world", fill=Image.core.fill) + with pytest.warns(DeprecationWarning): + with pytest.raises(TypeError): + font.getmask2("Hello world", fill=None) def test_render_mono_size(): diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index 39d91eade..fe7ac9a7a 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -6,10 +6,8 @@ from PIL import Image, ImageMath def pixel(im): if hasattr(im, "im"): return f"{im.mode} {repr(im.getpixel((0, 0)))}" - else: - if isinstance(im, int): - return int(im) # hack to deal with booleans - print(im) + if isinstance(im, int): + return int(im) # hack to deal with booleans A = Image.new("L", (1, 1), 1) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 6de953068..29c71f917 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -65,14 +65,16 @@ def create_lut(): # create_lut() -def test_lut(): - for op in ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge"): - lb = ImageMorph.LutBuilder(op_name=op) - assert lb.get_lut() is None +@pytest.mark.parametrize( + "op", ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge") +) +def test_lut(op): + lb = ImageMorph.LutBuilder(op_name=op) + assert lb.get_lut() is None - lut = lb.build_lut() - with open(f"Tests/images/{op}.lut", "rb") as f: - assert lut == bytearray(f.read()) + lut = lb.build_lut() + with open(f"Tests/images/{op}.lut", "rb") as f: + assert lut == bytearray(f.read()) def test_no_operator_loaded(): diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 01e40e6d4..c9b2fd865 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -110,6 +110,16 @@ def test_contain(new_size): assert new_im.size == (256, 256) +def test_contain_round(): + im = Image.new("1", (43, 63), 1) + new_im = ImageOps.contain(im, (5, 7)) + assert new_im.width == 5 + + im = Image.new("1", (63, 43), 1) + new_im = ImageOps.contain(im, (7, 5)) + assert new_im.height == 5 + + def test_pad(): # Same ratio im = hopper() @@ -130,6 +140,30 @@ def test_pad(): ) +def test_pad_round(): + im = Image.new("1", (1, 1), 1) + new_im = ImageOps.pad(im, (4, 1)) + assert new_im.load()[2, 0] == 1 + + new_im = ImageOps.pad(im, (1, 4)) + assert new_im.load()[0, 2] == 1 + + +@pytest.mark.parametrize("mode", ("P", "PA")) +def test_palette(mode): + im = hopper(mode) + + # Expand + expanded_im = ImageOps.expand(im) + assert_image_equal(im.convert("RGB"), expanded_im.convert("RGB")) + + # Pad + padded_im = ImageOps.pad(im, (256, 128), centering=(0, 0)) + assert_image_equal( + im.convert("RGB"), padded_im.convert("RGB").crop((0, 0, 128, 128)) + ) + + def test_pil163(): # Division by zero in equalize if < 255 pixels in image (@PIL163) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 475d249ed..5bda28117 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -50,6 +50,16 @@ def test_getcolor(): palette.getcolor("unknown") +def test_getcolor_rgba_color_rgb_palette(): + palette = ImagePalette.ImagePalette("RGB") + + # Opaque RGBA colors are converted + assert palette.getcolor((0, 0, 0, 255)) == palette.getcolor((0, 0, 0)) + + with pytest.raises(ValueError): + palette.getcolor((0, 0, 0, 128)) + + @pytest.mark.parametrize( "index, palette", [ diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index 55d7c9479..3e147a9ef 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -45,10 +45,10 @@ def test_viewer_show(order): not on_ci() or is_win32(), reason="Only run on CIs; hangs on Windows CIs", ) -def test_show(): - for mode in ("1", "I;16", "LA", "RGB", "RGBA"): - im = hopper(mode) - assert ImageShow.show(im) +@pytest.mark.parametrize("mode", ("1", "I;16", "LA", "RGB", "RGBA")) +def test_show(mode): + im = hopper(mode) + assert ImageShow.show(im) def test_show_without_viewers(): @@ -70,12 +70,12 @@ def test_viewer(): viewer.get_command(None) -def test_viewers(): - for viewer in ImageShow._viewers: - try: - viewer.get_command("test.jpg") - except NotImplementedError: - pass +@pytest.mark.parametrize("viewer", ImageShow._viewers) +def test_viewers(viewer): + try: + viewer.get_command("test.jpg") + except NotImplementedError: + pass def test_ipythonviewer(): @@ -95,14 +95,14 @@ def test_ipythonviewer(): not on_ci() or is_win32(), reason="Only run on CIs; hangs on Windows CIs", ) -def test_file_deprecated(tmp_path): +@pytest.mark.parametrize("viewer", ImageShow._viewers) +def test_file_deprecated(tmp_path, viewer): f = str(tmp_path / "temp.jpg") - for viewer in ImageShow._viewers: - hopper().save(f) - with pytest.warns(DeprecationWarning): - try: - viewer.show_file(file=f) - except NotImplementedError: - pass - with pytest.raises(TypeError): - viewer.show_file() + hopper().save(f) + with pytest.warns(DeprecationWarning): + try: + viewer.show_file(file=f) + except NotImplementedError: + pass + with pytest.raises(TypeError): + viewer.show_file() diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index a929910b3..995d0ee1f 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -54,32 +54,39 @@ def test_kw(): assert im is None -def test_photoimage(): - for mode in TK_MODES: - # test as image: - im = hopper(mode) +@pytest.mark.parametrize("mode", TK_MODES) +def test_photoimage(mode): + # test as image: + im = hopper(mode) - # this should not crash + # this should not crash + im_tk = ImageTk.PhotoImage(im) + + assert im_tk.width() == im.width + assert im_tk.height() == im.height + + reloaded = ImageTk.getimage(im_tk) + assert_image_equal(reloaded, im.convert("RGBA")) + + +def test_photoimage_apply_transparency(): + with Image.open("Tests/images/pil123p.png") as im: im_tk = ImageTk.PhotoImage(im) - - assert im_tk.width() == im.width - assert im_tk.height() == im.height - reloaded = ImageTk.getimage(im_tk) assert_image_equal(reloaded, im.convert("RGBA")) -def test_photoimage_blank(): +@pytest.mark.parametrize("mode", TK_MODES) +def test_photoimage_blank(mode): # test a image using mode/size: - for mode in TK_MODES: - im_tk = ImageTk.PhotoImage(mode, (100, 100)) + im_tk = ImageTk.PhotoImage(mode, (100, 100)) - assert im_tk.width() == 100 - assert im_tk.height() == 100 + assert im_tk.width() == 100 + assert im_tk.height() == 100 - im = Image.new(mode, (100, 100)) - reloaded = ImageTk.getimage(im_tk) - assert_image_equal(reloaded.convert(mode), im) + im = Image.new(mode, (100, 100)) + reloaded = ImageTk.getimage(im_tk) + assert_image_equal(reloaded.convert(mode), im) def test_box_deprecation(): diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py index 6e8a2ac58..efcdab9ec 100644 --- a/Tests/test_mode_i16.py +++ b/Tests/test_mode_i16.py @@ -1,3 +1,5 @@ +import pytest + from PIL import Image from .helper import hopper @@ -20,65 +22,56 @@ def verify(im1): ), f"got {repr(p1)} from mode {im1.mode} at {xy}, expected {repr(p2)}" -def test_basic(tmp_path): +@pytest.mark.parametrize("mode", ("L", "I;16", "I;16B", "I;16L", "I")) +def test_basic(tmp_path, mode): # PIL 1.1 has limited support for 16-bit image data. Check that # create/copy/transform and save works as expected. - def basic(mode): + im_in = original.convert(mode) + verify(im_in) - im_in = original.convert(mode) - verify(im_in) + w, h = im_in.size - w, h = im_in.size + im_out = im_in.copy() + verify(im_out) # copy - im_out = im_in.copy() - verify(im_out) # copy + im_out = im_in.transform((w, h), Image.Transform.EXTENT, (0, 0, w, h)) + verify(im_out) # transform - im_out = im_in.transform((w, h), Image.Transform.EXTENT, (0, 0, w, h)) - verify(im_out) # transform + filename = str(tmp_path / "temp.im") + im_in.save(filename) - filename = str(tmp_path / "temp.im") - im_in.save(filename) - - with Image.open(filename) as im_out: - - verify(im_in) - verify(im_out) - - im_out = im_in.crop((0, 0, w, h)) - verify(im_out) - - im_out = Image.new(mode, (w, h), None) - im_out.paste(im_in.crop((0, 0, w // 2, h)), (0, 0)) - im_out.paste(im_in.crop((w // 2, 0, w, h)), (w // 2, 0)) + with Image.open(filename) as im_out: verify(im_in) verify(im_out) - im_in = Image.new(mode, (1, 1), 1) - assert im_in.getpixel((0, 0)) == 1 + im_out = im_in.crop((0, 0, w, h)) + verify(im_out) - im_in.putpixel((0, 0), 2) - assert im_in.getpixel((0, 0)) == 2 + im_out = Image.new(mode, (w, h), None) + im_out.paste(im_in.crop((0, 0, w // 2, h)), (0, 0)) + im_out.paste(im_in.crop((w // 2, 0, w, h)), (w // 2, 0)) - if mode == "L": - maximum = 255 - else: - maximum = 32767 + verify(im_in) + verify(im_out) - im_in = Image.new(mode, (1, 1), 256) - assert im_in.getpixel((0, 0)) == min(256, maximum) + im_in = Image.new(mode, (1, 1), 1) + assert im_in.getpixel((0, 0)) == 1 - im_in.putpixel((0, 0), 512) - assert im_in.getpixel((0, 0)) == min(512, maximum) + im_in.putpixel((0, 0), 2) + assert im_in.getpixel((0, 0)) == 2 - basic("L") + if mode == "L": + maximum = 255 + else: + maximum = 32767 - basic("I;16") - basic("I;16B") - basic("I;16L") + im_in = Image.new(mode, (1, 1), 256) + assert im_in.getpixel((0, 0)) == min(256, maximum) - basic("I") + im_in.putpixel((0, 0), 512) + assert im_in.getpixel((0, 0)) == min(512, maximum) def test_tobytes(): diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 9735837bc..185e477ec 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -137,19 +137,9 @@ def test_save_tiff_uint16(): assert img_px[0, 0] == pixel_value -def test_to_array(): - def _to_array(mode, dtype): - img = hopper(mode) - - # Resize to non-square - img = img.crop((3, 0, 124, 127)) - assert img.size == (121, 127) - - np_img = numpy.array(img) - _test_img_equals_nparray(img, np_img) - assert np_img.dtype == dtype - - modes = [ +@pytest.mark.parametrize( + "mode, dtype", + ( ("L", numpy.uint8), ("I", numpy.int32), ("F", numpy.float32), @@ -163,10 +153,18 @@ def test_to_array(): ("I;16B", ">u2"), ("I;16L", " /dev/null 2>&1 || $(PYTHON) -m pip install sphinx - $(PYTHON) -c "import furo" > /dev/null 2>&1 || $(PYTHON) -m pip install furo + $(PYTHON) -m pip install --quiet sphinx sphinx-copybutton sphinx-issues sphinx-removed-in sphinxext-opengraph furo olefile html: $(MAKE) install-sphinx diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 9be92770a..dec652df8 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -178,6 +178,8 @@ Image.coerce_e This undocumented method has been deprecated and will be removed in Pillow 10 (2023-07-01). +.. _Font size and offset methods: + Font size and offset methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -197,6 +199,40 @@ Deprecated Use :py:meth:`.ImageDraw2.Draw.textsize` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` =========================================================================== ============================================================================================================= +Previous code: + +.. code-block:: python + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + width, height = font.getsize("Hello world") + left, top = font.getoffset("Hello world") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width, height = draw.textsize("Hello world") + + width, height = font.getsize_multiline("Hello\nworld") + width, height = draw.multiline_textsize("Hello\nworld") + +Use instead: + +.. code-block:: python + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + left, top, right, bottom = font.getbbox("Hello world") + width, height = right - left, bottom - top + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width = draw.textlength("Hello world") + + left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") + width, height = right - left, bottom - top + Removed features ---------------- @@ -253,7 +289,7 @@ Support for FreeType 2.7 has been removed. We recommend upgrading to at least `FreeType`_ 2.10.4, which fixed a severe vulnerability introduced in FreeType 2.6 (:cve:`CVE-2020-15999`). -.. _FreeType: https://www.freetype.org +.. _FreeType: https://freetype.org/ im.offset ~~~~~~~~~ diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 66eeaf6f8..a9b33e437 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -60,7 +60,10 @@ Pillow also provides limited support for a few additional modes, including: * ``BGR;24`` (24-bit reversed true colour) * ``BGR;32`` (32-bit reversed true colour) -However, Pillow doesn’t support user-defined modes; if you need to handle band +Apart from these additional modes, Pillow doesn't yet support multichannel +images with a depth of more than 8 bits per channel. + +Pillow also doesn’t support user-defined modes; if you need to handle band combinations that are not listed above, use a sequence of Image objects. You can read the mode of an image through the :py:attr:`~PIL.Image.Image.mode` diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 7db7b117a..1e79db68b 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -31,6 +31,9 @@ BLP is the Blizzard Mipmap Format, a texture format used in World of Warcraft. Pillow supports reading ``JPEG`` Compressed or raw ``BLP1`` images, and all types of ``BLP2`` images. +Saving +~~~~~~ + Pillow supports writing BLP images. The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: @@ -42,15 +45,19 @@ BMP ^^^ Pillow reads and writes Windows and OS/2 BMP files containing ``1``, ``L``, ``P``, -or ``RGB`` data. 16-colour images are read as ``P`` images. 4-bit run-length encoding -is not supported. Support for reading 8-bit run-length encoding was added in Pillow -9.1.0. +or ``RGB`` data. 16-colour images are read as ``P`` images. +Support for reading 8-bit run-length encoding was added in Pillow 9.1.0. +Support for reading 4-bit run-length encoding was added in Pillow 9.3.0. + +Opening +~~~~~~~ The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: **compression** - Set to ``bmp_rle`` if the file is run-length encoded. + Set to 1 if the file is a 256-color run-length encoded image. + Set to 2 if the file is a 16-color run-length encoded image. DDS ^^^ @@ -78,6 +85,9 @@ EPS images. The EPS driver can read EPS images in ``L``, ``LAB``, ``RGB`` and than leaving them in the original color space. The EPS driver can write images in ``L``, ``RGB`` and ``CMYK`` modes. +Loading +~~~~~~~ + If Ghostscript is available, you can call the :py:meth:`~PIL.Image.Image.load` method with the following parameters to affect how Ghostscript renders the EPS @@ -134,6 +144,11 @@ To restore the default behavior, where ``P`` mode images are only converted to from PIL import GifImagePlugin GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST +.. _gif-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -171,6 +186,8 @@ to seek to the next frame (``im.seek(im.tell() + 1)``). ``im.seek()`` raises an :py:exc:`EOFError` if you try to seek after the last frame. +.. _gif-saving: + Saving ~~~~~~ @@ -278,6 +295,11 @@ sets the following :py:attr:`~PIL.Image.Image.info` property: ask for ``(512, 512, 2)``, the final value of :py:attr:`~PIL.Image.Image.size` will be ``(1024, 1024)``). +.. _icns-saving: + +Saving +~~~~~~ + The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: **append_images** @@ -292,6 +314,11 @@ ICO ICO is used to store icons on Windows. The largest available icon is read. +.. _ico-saving: + +Saving +~~~~~~ + The :py:meth:`~PIL.Image.Image.save` method supports the following options: **sizes** @@ -337,6 +364,11 @@ their original size while loading them. By default Pillow doesn't allow loading of truncated JPEG files, set :data:`.ImageFile.LOAD_TRUNCATED_IMAGES` to override this. +.. _jpeg-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method may set the following :py:attr:`~PIL.Image.Image.info` properties if available: @@ -383,6 +415,10 @@ The :py:meth:`~PIL.Image.open` method may set the following .. versionadded:: 7.1.0 +.. _jpeg-saving: + +Saving +~~~~~~ The :py:meth:`~PIL.Image.Image.save` method supports the following options: @@ -464,6 +500,11 @@ itself. It is also possible to set ``reduce`` to the number of resolutions to discard (each one reduces the size of the resulting image by a factor of 2), and ``layers`` to specify the number of quality layers to load. +.. _jpeg-2000-saving: + +Saving +~~~~~~ + The :py:meth:`~PIL.Image.Image.save` method supports the following options: **offset** @@ -575,6 +616,11 @@ called. By default Pillow doesn't allow loading of truncated PNG files, set :data:`.ImageFile.LOAD_TRUNCATED_IMAGES` to override this. +.. _png-opening: + +Opening +~~~~~~~ + The :py:func:`~PIL.Image.open` function sets the following :py:attr:`~PIL.Image.Image.info` properties, when appropriate: @@ -613,6 +659,11 @@ decompression bombs. Additionally, the total size of all of the text chunks is limited to :data:`.PngImagePlugin.MAX_TEXT_MEMORY`, defaulting to 64MB. +.. _png-saving: + +Saving +~~~~~~ + The :py:meth:`~PIL.Image.Image.save` method supports the following options: **optimize** @@ -803,6 +854,11 @@ Pillow also reads SPIDER stack files containing sequences of SPIDER images. The :py:meth:`~PIL.Image.Image.seek` and :py:meth:`~PIL.Image.Image.tell` methods are supported, and random access is allowed. +.. _spider-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following attributes: **format** @@ -819,8 +875,10 @@ is provided for converting floating point data to byte data (mode ``L``):: im = Image.open("image001.spi").convert2byte() -Writing files in SPIDER format -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. _spider-saving: + +Saving +~~~~~~ The extension of SPIDER files may be any 3 alphanumeric characters. Therefore the output format must be specified explicitly:: @@ -837,6 +895,29 @@ Pillow reads and writes TGA images containing ``L``, ``LA``, ``P``, ``RGB``, and ``RGBA`` data. Pillow can read and write both uncompressed and run-length encoded TGAs. +.. _tga-saving: + +Saving +~~~~~~ + +The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: + +**compression** + If set to "tga_rle", the file will be run-length encoded. + + .. versionadded:: 5.3.0 + +**id_section** + The identification field. + + .. versionadded:: 5.3.0 + +**orientation** + If present and a positive number, the first pixel is for the top left corner, + rather than the bottom left corner. + + .. versionadded:: 5.3.0 + TIFF ^^^^ @@ -853,6 +934,11 @@ uncompressed files. support for reading Packbits, LZW and JPEG compressed TIFFs without using libtiff. +.. _tiff-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -904,8 +990,10 @@ and can be accessed in any order. ``im.seek()`` raises an :py:exc:`EOFError` if you try to seek after the last frame. -Saving Tiff Images -~~~~~~~~~~~~~~~~~~ +.. _tiff-saving: + +Saving +~~~~~~ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: @@ -1017,6 +1105,11 @@ WebP Pillow reads and writes WebP files. The specifics of Pillow's capabilities with this format are currently undocumented. +.. _webp-saving: + +Saving +~~~~~~ + The :py:meth:`~PIL.Image.Image.save` method supports the following options: **lossless** @@ -1040,7 +1133,7 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: the system WebP library was built with webpmux support. Saving sequences -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~ .. note:: @@ -1155,6 +1248,11 @@ GBR The GBR decoder reads GIMP brush files, version 1 and 2. +.. _gbr-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -1170,6 +1268,11 @@ GD Pillow reads uncompressed GD2 files. Note that you must use :py:func:`PIL.GdImageFile.open` to read such a file. +.. _gd-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -1209,6 +1312,11 @@ image when first opened. The :py:meth:`~PIL.Image.Image.seek` and :py:meth:`~PIL methods may be used to read other pictures from the file. The pictures are zero-indexed and random access is supported. +.. _mpo-saving: + +Saving +~~~~~~ + When calling :py:meth:`~PIL.Image.Image.save` to write an MPO file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` argument is present and true, then all frames will be saved, and the following @@ -1308,6 +1416,11 @@ XPM Pillow reads X pixmap files (mode ``P``) with 256 colors or less. +.. _xpm-opening: + +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -1332,6 +1445,11 @@ Pillow can write PDF (Acrobat) images. Such images are written as binary PDF 1.4 files, using either JPEG or HEX encoding depending on the image mode (and whether JPEG support is available or not). +.. _pdf-saving: + +Saving +~~~~~~ + The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: **save_all** diff --git a/docs/index.rst b/docs/index.rst index c731e2746..45af4c571 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -69,6 +69,10 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more `_ + `_ (``fribidi-0.dll`` or ``libfribidi-0.dll`` are also detected). See `Build Options`_ to see how to build this version. * Previous versions of Pillow (5.0.0 to 8.1.2) linked libraqm dynamically at runtime. diff --git a/docs/reference/ExifTags.rst b/docs/reference/ExifTags.rst index 4567d4d3e..ff5788524 100644 --- a/docs/reference/ExifTags.rst +++ b/docs/reference/ExifTags.rst @@ -10,8 +10,8 @@ provide constants and clear-text names for various well-known EXIF tags. .. py:data:: TAGS :type: dict - The TAG dictionary maps 16-bit integer EXIF tag enumerations to - descriptive string names. For instance: + The TAGS dictionary maps 16-bit integer EXIF tag enumerations to + descriptive string names. For instance: >>> from PIL.ExifTags import TAGS >>> TAGS[0x010e] @@ -20,9 +20,28 @@ provide constants and clear-text names for various well-known EXIF tags. .. py:data:: GPSTAGS :type: dict - The GPSTAGS dictionary maps 8-bit integer EXIF gps enumerations to - descriptive string names. For instance: + The GPSTAGS dictionary maps 8-bit integer EXIF GPS enumerations to + descriptive string names. For instance: >>> from PIL.ExifTags import GPSTAGS >>> GPSTAGS[20] 'GPSDestLatitude' + + +These values are also exposed as ``enum.IntEnum`` classes. + +.. py:data:: Base + + >>> from PIL.ExifTags import Base + >>> Base.ImageDescription.value + 270 + >>> Base(270).name + 'ImageDescription' + +.. py:data:: GPS + + >>> from PIL.ExifTags import GPS + >>> GPS.GPSDestLatitude.value + 20 + >>> GPS(20).name + 'GPSDestLatitude' diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index ed37521fd..7f6f666c3 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -53,9 +53,9 @@ Functions To protect against potential DOS attacks caused by "`decompression bombs`_" (i.e. malicious files which decompress into a huge amount of data and are designed to crash or cause disruption by using up a lot of memory), Pillow will issue a ``DecompressionBombWarning`` if the number of pixels in an - image is over a certain limit, :py:data:`PIL.Image.MAX_IMAGE_PIXELS`. + image is over a certain limit, :py:data:`MAX_IMAGE_PIXELS`. - This threshold can be changed by setting :py:data:`PIL.Image.MAX_IMAGE_PIXELS`. It can be disabled + This threshold can be changed by setting :py:data:`MAX_IMAGE_PIXELS`. It can be disabled by setting ``Image.MAX_IMAGE_PIXELS = None``. If desired, the warning can be turned into an error with @@ -63,7 +63,7 @@ Functions ``warnings.simplefilter('ignore', Image.DecompressionBombWarning)``. See also `the logging documentation`_ to have warnings output to the logging facility instead of stderr. - If the number of pixels is greater than twice :py:data:`PIL.Image.MAX_IMAGE_PIXELS`, then a + If the number of pixels is greater than twice :py:data:`MAX_IMAGE_PIXELS`, then a ``DecompressionBombError`` will be raised instead. .. _decompression bombs: https://en.wikipedia.org/wiki/Zip_bomb @@ -255,7 +255,7 @@ This rotates the input image by ``theta`` degrees counter clockwise: .. automethod:: PIL.Image.Image.transform .. automethod:: PIL.Image.Image.transpose -This flips the input image by using the :data:`PIL.Image.Transpose.FLIP_LEFT_RIGHT` +This flips the input image by using the :data:`Transpose.FLIP_LEFT_RIGHT` method. .. code-block:: python diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 1ef9079fb..9aa26916a 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -139,17 +139,50 @@ Functions must be the same as the image mode. If omitted, the mode defaults to the mode of the image. +Attributes +---------- + +.. py:attribute:: ImageDraw.fill + :type: bool + :value: False + + Selects whether :py:attr:`ImageDraw.ink` should be used as a fill or outline color. + +.. py:attribute:: ImageDraw.font + + The current default font. + + Can be set per instance:: + + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(image) + draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + Or globally for all future ImageDraw instances:: + + from PIL import ImageDraw, ImageFont + ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + +.. py:attribute:: ImageDraw.fontmode + + The current font drawing mode. + + Set to ``"1"`` to disable antialiasing or ``"L"`` to enable it. + +.. py:attribute:: ImageDraw.ink + :type: int + + The internal representation of the current default color. + Methods ------- .. py:method:: ImageDraw.getfont() - Get the current default font. + Get the current default font, :py:attr:`ImageDraw.font`. - To set the default font for all future ImageDraw instances:: - - from PIL import ImageDraw, ImageFont - ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + If the current default font is ``None``, + it is initialized with :py:func:`.ImageFont.load_default`. :returns: An image font. @@ -285,8 +318,8 @@ Methods Draws a rectangle. :param xy: Two points to define the bounding box. Sequence of either - ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``. The second point - is just outside the drawn rectangle. + ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``. The bounding box + is inclusive of both endpoints. :param outline: Color to use for the outline. :param fill: Color to use for the fill. :param width: The line width, in pixels. @@ -298,8 +331,8 @@ Methods Draws a rounded rectangle. :param xy: Two points to define the bounding box. Sequence of either - ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``. The second point - is just outside the drawn rectangle. + ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``. The bounding box + is inclusive of both endpoints. :param radius: Radius of the corners. :param outline: Color to use for the outline. :param fill: Color to use for the fill. @@ -443,6 +476,8 @@ Methods .. deprecated:: 9.2.0 + See :ref:`deprecations ` for more information. + Use :py:meth:`textlength()` to measure the offset of following text with 1/64 pixel precision. Use :py:meth:`textbbox()` to get the exact bounding box based on an anchor. @@ -489,10 +524,14 @@ Methods .. versionadded:: 6.2.0 + :return: (width, height) + .. py:method:: ImageDraw.multiline_textsize(text, font=None, spacing=4, direction=None, features=None, language=None, stroke_width=0) .. deprecated:: 9.2.0 + See :ref:`deprecations ` for more information. + Use :py:meth:`.multiline_textbbox` instead. Return the size of the given string, in pixels. @@ -541,6 +580,8 @@ Methods .. versionadded:: 6.2.0 + :return: (width, height) + .. py:method:: ImageDraw.textlength(text, font=None, direction=None, features=None, language=None, embedded_color=False) Returns length (in pixels with 1/64 precision) of given text when rendered @@ -608,6 +649,7 @@ Methods It should be a `BCP 47 language code`_. Requires libraqm. :param embedded_color: Whether to use font embedded color glyphs (COLR, CBDT, SBIX). + :return: Width for horizontal, height for vertical text. .. py:method:: ImageDraw.textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False) @@ -657,6 +699,7 @@ Methods Requires libraqm. :param stroke_width: The width of the text stroke. :param embedded_color: Whether to use font embedded color glyphs (COLR, CBDT, SBIX). + :return: ``(left, top, right, bottom)`` bounding box .. py:method:: ImageDraw.multiline_textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False) @@ -700,6 +743,7 @@ Methods Requires libraqm. :param stroke_width: The width of the text stroke. :param embedded_color: Whether to use font embedded color glyphs (COLR, CBDT, SBIX). + :return: ``(left, top, right, bottom)`` bounding box .. py:method:: getdraw(im=None, hints=None) @@ -731,4 +775,4 @@ Methods homogeneous, but similar, colors. .. _BCP 47 language code: https://www.w3.org/International/articles/language-tags/ -.. _OpenType docs: https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist +.. _OpenType docs: https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst index d2e80fb8c..b234b7b4e 100644 --- a/docs/reference/PixelAccess.rst +++ b/docs/reference/PixelAccess.rst @@ -73,7 +73,7 @@ Access using negative indexes is also possible. Modifies the pixel at x,y. The color is given as a single numerical value for single band images, and a tuple for multi-band images. In addition to this, RGB and RGBA tuples - are accepted for P images. + are accepted for P and PA images. :param xy: The pixel coordinate, given as (x, y). :param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode) diff --git a/docs/releasenotes/5.2.0.rst b/docs/releasenotes/5.2.0.rst index 75e8da655..d9b8f0fb7 100644 --- a/docs/releasenotes/5.2.0.rst +++ b/docs/releasenotes/5.2.0.rst @@ -105,7 +105,7 @@ Resolve confusion getting PIL / Pillow version string Re: "version constants deprecated" listed above, as user gnbl notes in #3082: - it's confusing that PIL.VERSION returns the version string of the former PIL instead of Pillow's -- there does not seem to be documentation on this version number (why this, will it ever change, ..) e.g. at https://pillow.readthedocs.io/en/5.1.x/about.html#why-a-fork +- ReadTheDocs documentation is missing for some version branches (why is this, will it ever change, ...) - it's confusing that PIL.version is a module and does not return the version information directly or hints on how to get it - the package information header is essentially useless (placeholder, does not even mention Pillow, nor the version) - PIL._version module documentation comment could explain how to access the version information diff --git a/docs/releasenotes/9.1.0.rst b/docs/releasenotes/9.1.0.rst index 57646e558..48ce6fef7 100644 --- a/docs/releasenotes/9.1.0.rst +++ b/docs/releasenotes/9.1.0.rst @@ -202,7 +202,7 @@ Pillow now builds binary wheels for musllinux, suitable for Linux distributions (rather than the glibc library used by manylinux wheels). See :pep:`656`. ImageShow temporary files on Unix -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When calling :py:meth:`~PIL.Image.Image.show` or using :py:mod:`~PIL.ImageShow`, a temporary file is created from the image. On Unix, Pillow will no longer delete these diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index 9c102f177..6dbfa2702 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -59,6 +59,40 @@ Deprecated Use :py:meth:`.ImageDraw2.Draw.textsize` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` =========================================================================== ============================================================================================================= +Previous code: + +.. code-block:: python + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + width, height = font.getsize("Hello world") + left, top = font.getoffset("Hello world") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width, height = draw.textsize("Hello world") + + width, height = font.getsize_multiline("Hello\nworld") + width, height = draw.multiline_textsize("Hello\nworld") + +Use instead: + +.. code-block:: python + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + left, top, right, bottom = font.getbbox("Hello world") + width, height = right - left, bottom - top + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width = draw.textlength("Hello world") + + left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") + width, height = right - left, bottom - top + API Additions ============= diff --git a/docs/releasenotes/9.3.0.rst b/docs/releasenotes/9.3.0.rst index 7109a09f2..a20ee4da6 100644 --- a/docs/releasenotes/9.3.0.rst +++ b/docs/releasenotes/9.3.0.rst @@ -4,25 +4,6 @@ Backwards Incompatible Changes ============================== -TODO -^^^^ - -Deprecations -============ - -TODO -^^^^ - -TODO - -API Changes -=========== - -TODO -^^^^ - -TODO - API Additions ============= @@ -51,19 +32,36 @@ Additional images can also be appended when saving, by combining the im.save(out, save_all=True, append_images=[im1, im2, ...]) +Added ExifTags enums +^^^^^^^^^^^^^^^^^^^^ + +The data from :py:data:`~PIL.ExifTags.TAGS` and +:py:data:`~PIL.ExifTags.GPSTAGS` is now also exposed as ``enum.IntEnum`` +classes: :py:data:`~PIL.ExifTags.Base` and :py:data:`~PIL.ExifTags.GPS`. + Security ======== -TODO -^^^^ +Decode JPEG compressed BLP1 data in original mode +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +Within the BLP image format, BLP1 data may use JPEG compression. Instead of +telling the JPEG library that this data is in BGRX mode, Pillow will now +decode the data in its natural CMYK mode, then convert it to RGB and rearrange +the channels afterwards. Trying to load the data in an incorrect mode could +result in a segmentation fault. This issue was introduced in Pillow 9.1.0. Other Changes ============= -Added DDS ATI1 and ATI2 reading -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Added DDS ATI1, ATI2 and BC6H reading +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Support has been added to read the ATI1 and ATI2 formats of DDS images. +Support has been added to read the ATI1, ATI2 and BC6H formats of DDS images. + +Show all frames with ImageShow +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When calling :py:meth:`~PIL.Image.Image.show` or using +:py:mod:`~PIL.ImageShow`, all frames will now be shown. diff --git a/docs/releasenotes/versioning.rst b/docs/releasenotes/versioning.rst index 87f2ba422..2a0af9e59 100644 --- a/docs/releasenotes/versioning.rst +++ b/docs/releasenotes/versioning.rst @@ -11,7 +11,7 @@ Pillow follows `Semantic Versioning `_: 2. MINOR version when you add functionality in a backwards compatible manner, and 3. PATCH version when you make backwards compatible bug fixes. -Quarterly releases ("`Main Release `_") +Quarterly releases ("`Main Release `_") bump at least the MINOR version, as new functionality has likely been added in the prior three months. @@ -21,8 +21,8 @@ these occur every 12-18 months, guided by `Python's EOL schedule `_, and any APIs that have been deprecated for at least a year are removed at the same time. -PATCH versions ("`Point Release `_" -or "`Embargoed Release `_") +PATCH versions ("`Point Release `_" +or "`Embargoed Release `_") are for security, installation or critical bug fixes. These are less common as it is preferred to stick to quarterly releases. diff --git a/setup.cfg b/setup.cfg index be3bc4b4f..44feb25ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,7 +34,11 @@ project_urls = Twitter=https://twitter.com/PythonPillow [options] +packages = PIL python_requires = >=3.7 +include_package_data = True +package_dir = + = src [options.extras_require] docs = diff --git a/setup.py b/setup.py index a2b2c6910..14c404752 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ from setuptools.command.build_ext import build_ext def get_version(): version_file = "src/PIL/_version.py" - with open(version_file) as f: + with open(version_file, encoding="utf-8") as f: exec(compile(f.read(), version_file, "exec")) return locals()["__version__"] @@ -999,9 +999,6 @@ try: version=PILLOW_VERSION, cmdclass={"build_ext": pil_build_ext}, ext_modules=ext_modules, - include_package_data=True, - packages=["PIL"], - package_dir={"": "src"}, zip_safe=not (debug_build() or PLATFORM_MINGW), ) except RequiredDependencyException as err: diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 104fbada9..533997737 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -373,8 +373,8 @@ class BLP1Decoder(_BLPBaseDecoder): data = BytesIO(data) image = JpegImageFile(data) Image._decompression_bomb_check(image.size) - image.mode = "RGB" - image.tile = [("jpeg", (0, 0) + self.size, 0, ("BGRX", ""))] + r, g, b = image.convert("RGB").split() + image = Image.merge("RGB", (b, g, r)) self.set_as_raw(image.tobytes()) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 7bb73fc93..bdf51aa5c 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -211,7 +211,7 @@ class BmpImageFile(ImageFile.ImageFile): elif file_info["compression"] == self.RAW: if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset raw_mode, self.mode = "BGRA", "RGBA" - elif file_info["compression"] == self.RLE8: + elif file_info["compression"] in (self.RLE8, self.RLE4): decoder_name = "bmp_rle" else: raise OSError(f"Unsupported BMP compression ({file_info['compression']})") @@ -250,16 +250,18 @@ class BmpImageFile(ImageFile.ImageFile): # ---------------------------- Finally set the tile data for the plugin self.info["compression"] = file_info["compression"] + args = [raw_mode] + if decoder_name == "bmp_rle": + args.append(file_info["compression"] == self.RLE4) + else: + args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) + args.append(file_info["direction"]) self.tile = [ ( decoder_name, (0, 0, file_info["width"], file_info["height"]), offset or self.fp.tell(), - ( - raw_mode, - ((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3), - file_info["direction"], - ), + tuple(args), ) ] @@ -280,6 +282,7 @@ class BmpRleDecoder(ImageFile.PyDecoder): _pulls_fd = True def decode(self, buffer): + rle4 = self.args[1] data = bytearray() x = 0 while len(data) < self.state.xsize * self.state.ysize: @@ -293,7 +296,16 @@ class BmpRleDecoder(ImageFile.PyDecoder): if x + num_pixels > self.state.xsize: # Too much data for row num_pixels = max(0, self.state.xsize - x) - data += byte * num_pixels + if rle4: + first_pixel = o8(byte[0] >> 4) + second_pixel = o8(byte[0] & 0x0F) + for index in range(num_pixels): + if index % 2 == 0: + data += first_pixel + else: + data += second_pixel + else: + data += byte * num_pixels x += num_pixels else: if byte[0] == 0: @@ -314,9 +326,18 @@ class BmpRleDecoder(ImageFile.PyDecoder): x = len(data) % self.state.xsize else: # absolute mode - bytes_read = self.fd.read(byte[0]) - data += bytes_read - if len(bytes_read) < byte[0]: + if rle4: + # 2 pixels per byte + byte_count = byte[0] // 2 + bytes_read = self.fd.read(byte_count) + for byte_read in bytes_read: + data += o8(byte_read >> 4) + data += o8(byte_read & 0x0F) + else: + byte_count = byte[0] + bytes_read = self.fd.read(byte_count) + data += bytes_read + if len(bytes_read) < byte_count: break x += byte[0] @@ -375,6 +396,16 @@ def _save(im, fp, filename, bitmap_header=True): header = 40 # or 64 for OS/2 version 2 image = stride * im.size[1] + if im.mode == "1": + palette = b"".join(o8(i) * 4 for i in (0, 255)) + elif im.mode == "L": + palette = b"".join(o8(i) * 4 for i in range(256)) + elif im.mode == "P": + palette = im.im.getpalette("RGB", "BGRX") + colors = len(palette) // 4 + else: + palette = None + # bitmap header if bitmap_header: offset = 14 + header + colors * 4 @@ -405,14 +436,8 @@ def _save(im, fp, filename, bitmap_header=True): fp.write(b"\0" * (header - 40)) # padding (for OS/2 format) - if im.mode == "1": - for i in (0, 255): - fp.write(o8(i) * 4) - elif im.mode == "L": - for i in range(256): - fp.write(o8(i) * 4) - elif im.mode == "P": - fp.write(im.im.getpalette("RGB", "BGRX")) + if palette: + fp.write(palette) ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index bba480161..eea6e3153 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -101,6 +101,8 @@ DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = 29 DXGI_FORMAT_BC5_TYPELESS = 82 DXGI_FORMAT_BC5_UNORM = 83 DXGI_FORMAT_BC5_SNORM = 84 +DXGI_FORMAT_BC6H_UF16 = 95 +DXGI_FORMAT_BC6H_SF16 = 96 DXGI_FORMAT_BC7_TYPELESS = 97 DXGI_FORMAT_BC7_UNORM = 98 DXGI_FORMAT_BC7_UNORM_SRGB = 99 @@ -181,6 +183,14 @@ class DdsImageFile(ImageFile.ImageFile): self.pixel_format = "BC5S" n = 5 self.mode = "RGB" + elif dxgi_format == DXGI_FORMAT_BC6H_UF16: + self.pixel_format = "BC6H" + n = 6 + self.mode = "RGB" + elif dxgi_format == DXGI_FORMAT_BC6H_SF16: + self.pixel_format = "BC6HS" + n = 6 + self.mode = "RGB" elif dxgi_format in (DXGI_FORMAT_BC7_TYPELESS, DXGI_FORMAT_BC7_UNORM): self.pixel_format = "BC7" n = 7 diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 3b782d6b3..0e434c5c0 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -288,11 +288,14 @@ class EpsImageFile(ImageFile.ImageFile): # Encoded bitmapped image. x, y, bi, mo = s[11:].split(None, 7)[:4] - if int(bi) != 8: - break - try: - self.mode = self.mode_map[int(mo)] - except ValueError: + if int(bi) == 1: + self.mode = "1" + elif int(bi) == 8: + try: + self.mode = self.mode_map[int(mo)] + except ValueError: + break + else: break self._size = int(x), int(y) diff --git a/src/PIL/ExifTags.py b/src/PIL/ExifTags.py index 7da2ddae5..f3a73bf1a 100644 --- a/src/PIL/ExifTags.py +++ b/src/PIL/ExifTags.py @@ -14,318 +14,327 @@ This module provides constants and clear-text names for various well-known EXIF tags. """ +from enum import IntEnum -TAGS = { + +class Base(IntEnum): # possibly incomplete - 0x0001: "InteropIndex", - 0x000B: "ProcessingSoftware", - 0x00FE: "NewSubfileType", - 0x00FF: "SubfileType", - 0x0100: "ImageWidth", - 0x0101: "ImageLength", - 0x0102: "BitsPerSample", - 0x0103: "Compression", - 0x0106: "PhotometricInterpretation", - 0x0107: "Thresholding", - 0x0108: "CellWidth", - 0x0109: "CellLength", - 0x010A: "FillOrder", - 0x010D: "DocumentName", - 0x010E: "ImageDescription", - 0x010F: "Make", - 0x0110: "Model", - 0x0111: "StripOffsets", - 0x0112: "Orientation", - 0x0115: "SamplesPerPixel", - 0x0116: "RowsPerStrip", - 0x0117: "StripByteCounts", - 0x0118: "MinSampleValue", - 0x0119: "MaxSampleValue", - 0x011A: "XResolution", - 0x011B: "YResolution", - 0x011C: "PlanarConfiguration", - 0x011D: "PageName", - 0x0120: "FreeOffsets", - 0x0121: "FreeByteCounts", - 0x0122: "GrayResponseUnit", - 0x0123: "GrayResponseCurve", - 0x0124: "T4Options", - 0x0125: "T6Options", - 0x0128: "ResolutionUnit", - 0x0129: "PageNumber", - 0x012D: "TransferFunction", - 0x0131: "Software", - 0x0132: "DateTime", - 0x013B: "Artist", - 0x013C: "HostComputer", - 0x013D: "Predictor", - 0x013E: "WhitePoint", - 0x013F: "PrimaryChromaticities", - 0x0140: "ColorMap", - 0x0141: "HalftoneHints", - 0x0142: "TileWidth", - 0x0143: "TileLength", - 0x0144: "TileOffsets", - 0x0145: "TileByteCounts", - 0x014A: "SubIFDs", - 0x014C: "InkSet", - 0x014D: "InkNames", - 0x014E: "NumberOfInks", - 0x0150: "DotRange", - 0x0151: "TargetPrinter", - 0x0152: "ExtraSamples", - 0x0153: "SampleFormat", - 0x0154: "SMinSampleValue", - 0x0155: "SMaxSampleValue", - 0x0156: "TransferRange", - 0x0157: "ClipPath", - 0x0158: "XClipPathUnits", - 0x0159: "YClipPathUnits", - 0x015A: "Indexed", - 0x015B: "JPEGTables", - 0x015F: "OPIProxy", - 0x0200: "JPEGProc", - 0x0201: "JpegIFOffset", - 0x0202: "JpegIFByteCount", - 0x0203: "JpegRestartInterval", - 0x0205: "JpegLosslessPredictors", - 0x0206: "JpegPointTransforms", - 0x0207: "JpegQTables", - 0x0208: "JpegDCTables", - 0x0209: "JpegACTables", - 0x0211: "YCbCrCoefficients", - 0x0212: "YCbCrSubSampling", - 0x0213: "YCbCrPositioning", - 0x0214: "ReferenceBlackWhite", - 0x02BC: "XMLPacket", - 0x1000: "RelatedImageFileFormat", - 0x1001: "RelatedImageWidth", - 0x1002: "RelatedImageLength", - 0x4746: "Rating", - 0x4749: "RatingPercent", - 0x800D: "ImageID", - 0x828D: "CFARepeatPatternDim", - 0x828E: "CFAPattern", - 0x828F: "BatteryLevel", - 0x8298: "Copyright", - 0x829A: "ExposureTime", - 0x829D: "FNumber", - 0x83BB: "IPTCNAA", - 0x8649: "ImageResources", - 0x8769: "ExifOffset", - 0x8773: "InterColorProfile", - 0x8822: "ExposureProgram", - 0x8824: "SpectralSensitivity", - 0x8825: "GPSInfo", - 0x8827: "ISOSpeedRatings", - 0x8828: "OECF", - 0x8829: "Interlace", - 0x882A: "TimeZoneOffset", - 0x882B: "SelfTimerMode", - 0x8830: "SensitivityType", - 0x8831: "StandardOutputSensitivity", - 0x8832: "RecommendedExposureIndex", - 0x8833: "ISOSpeed", - 0x8834: "ISOSpeedLatitudeyyy", - 0x8835: "ISOSpeedLatitudezzz", - 0x9000: "ExifVersion", - 0x9003: "DateTimeOriginal", - 0x9004: "DateTimeDigitized", - 0x9010: "OffsetTime", - 0x9011: "OffsetTimeOriginal", - 0x9012: "OffsetTimeDigitized", - 0x9101: "ComponentsConfiguration", - 0x9102: "CompressedBitsPerPixel", - 0x9201: "ShutterSpeedValue", - 0x9202: "ApertureValue", - 0x9203: "BrightnessValue", - 0x9204: "ExposureBiasValue", - 0x9205: "MaxApertureValue", - 0x9206: "SubjectDistance", - 0x9207: "MeteringMode", - 0x9208: "LightSource", - 0x9209: "Flash", - 0x920A: "FocalLength", - 0x920B: "FlashEnergy", + InteropIndex = 0x0001 + ProcessingSoftware = 0x000B + NewSubfileType = 0x00FE + SubfileType = 0x00FF + ImageWidth = 0x0100 + ImageLength = 0x0101 + BitsPerSample = 0x0102 + Compression = 0x0103 + PhotometricInterpretation = 0x0106 + Thresholding = 0x0107 + CellWidth = 0x0108 + CellLength = 0x0109 + FillOrder = 0x010A + DocumentName = 0x010D + ImageDescription = 0x010E + Make = 0x010F + Model = 0x0110 + StripOffsets = 0x0111 + Orientation = 0x0112 + SamplesPerPixel = 0x0115 + RowsPerStrip = 0x0116 + StripByteCounts = 0x0117 + MinSampleValue = 0x0118 + MaxSampleValue = 0x0119 + XResolution = 0x011A + YResolution = 0x011B + PlanarConfiguration = 0x011C + PageName = 0x011D + FreeOffsets = 0x0120 + FreeByteCounts = 0x0121 + GrayResponseUnit = 0x0122 + GrayResponseCurve = 0x0123 + T4Options = 0x0124 + T6Options = 0x0125 + ResolutionUnit = 0x0128 + PageNumber = 0x0129 + TransferFunction = 0x012D + Software = 0x0131 + DateTime = 0x0132 + Artist = 0x013B + HostComputer = 0x013C + Predictor = 0x013D + WhitePoint = 0x013E + PrimaryChromaticities = 0x013F + ColorMap = 0x0140 + HalftoneHints = 0x0141 + TileWidth = 0x0142 + TileLength = 0x0143 + TileOffsets = 0x0144 + TileByteCounts = 0x0145 + SubIFDs = 0x014A + InkSet = 0x014C + InkNames = 0x014D + NumberOfInks = 0x014E + DotRange = 0x0150 + TargetPrinter = 0x0151 + ExtraSamples = 0x0152 + SampleFormat = 0x0153 + SMinSampleValue = 0x0154 + SMaxSampleValue = 0x0155 + TransferRange = 0x0156 + ClipPath = 0x0157 + XClipPathUnits = 0x0158 + YClipPathUnits = 0x0159 + Indexed = 0x015A + JPEGTables = 0x015B + OPIProxy = 0x015F + JPEGProc = 0x0200 + JpegIFOffset = 0x0201 + JpegIFByteCount = 0x0202 + JpegRestartInterval = 0x0203 + JpegLosslessPredictors = 0x0205 + JpegPointTransforms = 0x0206 + JpegQTables = 0x0207 + JpegDCTables = 0x0208 + JpegACTables = 0x0209 + YCbCrCoefficients = 0x0211 + YCbCrSubSampling = 0x0212 + YCbCrPositioning = 0x0213 + ReferenceBlackWhite = 0x0214 + XMLPacket = 0x02BC + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageLength = 0x1002 + Rating = 0x4746 + RatingPercent = 0x4749 + ImageID = 0x800D + CFARepeatPatternDim = 0x828D + BatteryLevel = 0x828F + Copyright = 0x8298 + ExposureTime = 0x829A + FNumber = 0x829D + IPTCNAA = 0x83BB + ImageResources = 0x8649 + ExifOffset = 0x8769 + InterColorProfile = 0x8773 + ExposureProgram = 0x8822 + SpectralSensitivity = 0x8824 + GPSInfo = 0x8825 + ISOSpeedRatings = 0x8827 + OECF = 0x8828 + Interlace = 0x8829 + TimeZoneOffset = 0x882A + SelfTimerMode = 0x882B + SensitivityType = 0x8830 + StandardOutputSensitivity = 0x8831 + RecommendedExposureIndex = 0x8832 + ISOSpeed = 0x8833 + ISOSpeedLatitudeyyy = 0x8834 + ISOSpeedLatitudezzz = 0x8835 + ExifVersion = 0x9000 + DateTimeOriginal = 0x9003 + DateTimeDigitized = 0x9004 + OffsetTime = 0x9010 + OffsetTimeOriginal = 0x9011 + OffsetTimeDigitized = 0x9012 + ComponentsConfiguration = 0x9101 + CompressedBitsPerPixel = 0x9102 + ShutterSpeedValue = 0x9201 + ApertureValue = 0x9202 + BrightnessValue = 0x9203 + ExposureBiasValue = 0x9204 + MaxApertureValue = 0x9205 + SubjectDistance = 0x9206 + MeteringMode = 0x9207 + LightSource = 0x9208 + Flash = 0x9209 + FocalLength = 0x920A + Noise = 0x920D + ImageNumber = 0x9211 + SecurityClassification = 0x9212 + ImageHistory = 0x9213 + TIFFEPStandardID = 0x9216 + MakerNote = 0x927C + UserComment = 0x9286 + SubsecTime = 0x9290 + SubsecTimeOriginal = 0x9291 + SubsecTimeDigitized = 0x9292 + AmbientTemperature = 0x9400 + Humidity = 0x9401 + Pressure = 0x9402 + WaterDepth = 0x9403 + Acceleration = 0x9404 + CameraElevationAngle = 0x9405 + XPTitle = 0x9C9B + XPComment = 0x9C9C + XPAuthor = 0x9C9D + XPKeywords = 0x9C9E + XPSubject = 0x9C9F + FlashPixVersion = 0xA000 + ColorSpace = 0xA001 + ExifImageWidth = 0xA002 + ExifImageHeight = 0xA003 + RelatedSoundFile = 0xA004 + ExifInteroperabilityOffset = 0xA005 + FlashEnergy = 0xA20B + SpatialFrequencyResponse = 0xA20C + FocalPlaneXResolution = 0xA20E + FocalPlaneYResolution = 0xA20F + FocalPlaneResolutionUnit = 0xA210 + SubjectLocation = 0xA214 + ExposureIndex = 0xA215 + SensingMethod = 0xA217 + FileSource = 0xA300 + SceneType = 0xA301 + CFAPattern = 0xA302 + CustomRendered = 0xA401 + ExposureMode = 0xA402 + WhiteBalance = 0xA403 + DigitalZoomRatio = 0xA404 + FocalLengthIn35mmFilm = 0xA405 + SceneCaptureType = 0xA406 + GainControl = 0xA407 + Contrast = 0xA408 + Saturation = 0xA409 + Sharpness = 0xA40A + DeviceSettingDescription = 0xA40B + SubjectDistanceRange = 0xA40C + ImageUniqueID = 0xA420 + CameraOwnerName = 0xA430 + BodySerialNumber = 0xA431 + LensSpecification = 0xA432 + LensMake = 0xA433 + LensModel = 0xA434 + LensSerialNumber = 0xA435 + CompositeImage = 0xA460 + CompositeImageCount = 0xA461 + CompositeImageExposureTimes = 0xA462 + Gamma = 0xA500 + PrintImageMatching = 0xC4A5 + DNGVersion = 0xC612 + DNGBackwardVersion = 0xC613 + UniqueCameraModel = 0xC614 + LocalizedCameraModel = 0xC615 + CFAPlaneColor = 0xC616 + CFALayout = 0xC617 + LinearizationTable = 0xC618 + BlackLevelRepeatDim = 0xC619 + BlackLevel = 0xC61A + BlackLevelDeltaH = 0xC61B + BlackLevelDeltaV = 0xC61C + WhiteLevel = 0xC61D + DefaultScale = 0xC61E + DefaultCropOrigin = 0xC61F + DefaultCropSize = 0xC620 + ColorMatrix1 = 0xC621 + ColorMatrix2 = 0xC622 + CameraCalibration1 = 0xC623 + CameraCalibration2 = 0xC624 + ReductionMatrix1 = 0xC625 + ReductionMatrix2 = 0xC626 + AnalogBalance = 0xC627 + AsShotNeutral = 0xC628 + AsShotWhiteXY = 0xC629 + BaselineExposure = 0xC62A + BaselineNoise = 0xC62B + BaselineSharpness = 0xC62C + BayerGreenSplit = 0xC62D + LinearResponseLimit = 0xC62E + CameraSerialNumber = 0xC62F + LensInfo = 0xC630 + ChromaBlurRadius = 0xC631 + AntiAliasStrength = 0xC632 + ShadowScale = 0xC633 + DNGPrivateData = 0xC634 + MakerNoteSafety = 0xC635 + CalibrationIlluminant1 = 0xC65A + CalibrationIlluminant2 = 0xC65B + BestQualityScale = 0xC65C + RawDataUniqueID = 0xC65D + OriginalRawFileName = 0xC68B + OriginalRawFileData = 0xC68C + ActiveArea = 0xC68D + MaskedAreas = 0xC68E + AsShotICCProfile = 0xC68F + AsShotPreProfileMatrix = 0xC690 + CurrentICCProfile = 0xC691 + CurrentPreProfileMatrix = 0xC692 + ColorimetricReference = 0xC6BF + CameraCalibrationSignature = 0xC6F3 + ProfileCalibrationSignature = 0xC6F4 + AsShotProfileName = 0xC6F6 + NoiseReductionApplied = 0xC6F7 + ProfileName = 0xC6F8 + ProfileHueSatMapDims = 0xC6F9 + ProfileHueSatMapData1 = 0xC6FA + ProfileHueSatMapData2 = 0xC6FB + ProfileToneCurve = 0xC6FC + ProfileEmbedPolicy = 0xC6FD + ProfileCopyright = 0xC6FE + ForwardMatrix1 = 0xC714 + ForwardMatrix2 = 0xC715 + PreviewApplicationName = 0xC716 + PreviewApplicationVersion = 0xC717 + PreviewSettingsName = 0xC718 + PreviewSettingsDigest = 0xC719 + PreviewColorSpace = 0xC71A + PreviewDateTime = 0xC71B + RawImageDigest = 0xC71C + OriginalRawFileDigest = 0xC71D + SubTileBlockSize = 0xC71E + RowInterleaveFactor = 0xC71F + ProfileLookTableDims = 0xC725 + ProfileLookTableData = 0xC726 + OpcodeList1 = 0xC740 + OpcodeList2 = 0xC741 + OpcodeList3 = 0xC74E + NoiseProfile = 0xC761 + + +"""Maps EXIF tags to tag names.""" +TAGS = { + **{i.value: i.name for i in Base}, 0x920C: "SpatialFrequencyResponse", - 0x920D: "Noise", - 0x9211: "ImageNumber", - 0x9212: "SecurityClassification", - 0x9213: "ImageHistory", 0x9214: "SubjectLocation", 0x9215: "ExposureIndex", + 0x828E: "CFAPattern", + 0x920B: "FlashEnergy", 0x9216: "TIFF/EPStandardID", - 0x927C: "MakerNote", - 0x9286: "UserComment", - 0x9290: "SubsecTime", - 0x9291: "SubsecTimeOriginal", - 0x9292: "SubsecTimeDigitized", - 0x9400: "AmbientTemperature", - 0x9401: "Humidity", - 0x9402: "Pressure", - 0x9403: "WaterDepth", - 0x9404: "Acceleration", - 0x9405: "CameraElevationAngle", - 0x9C9B: "XPTitle", - 0x9C9C: "XPComment", - 0x9C9D: "XPAuthor", - 0x9C9E: "XPKeywords", - 0x9C9F: "XPSubject", - 0xA000: "FlashPixVersion", - 0xA001: "ColorSpace", - 0xA002: "ExifImageWidth", - 0xA003: "ExifImageHeight", - 0xA004: "RelatedSoundFile", - 0xA005: "ExifInteroperabilityOffset", - 0xA20B: "FlashEnergy", - 0xA20C: "SpatialFrequencyResponse", - 0xA20E: "FocalPlaneXResolution", - 0xA20F: "FocalPlaneYResolution", - 0xA210: "FocalPlaneResolutionUnit", - 0xA214: "SubjectLocation", - 0xA215: "ExposureIndex", - 0xA217: "SensingMethod", - 0xA300: "FileSource", - 0xA301: "SceneType", - 0xA302: "CFAPattern", - 0xA401: "CustomRendered", - 0xA402: "ExposureMode", - 0xA403: "WhiteBalance", - 0xA404: "DigitalZoomRatio", - 0xA405: "FocalLengthIn35mmFilm", - 0xA406: "SceneCaptureType", - 0xA407: "GainControl", - 0xA408: "Contrast", - 0xA409: "Saturation", - 0xA40A: "Sharpness", - 0xA40B: "DeviceSettingDescription", - 0xA40C: "SubjectDistanceRange", - 0xA420: "ImageUniqueID", - 0xA430: "CameraOwnerName", - 0xA431: "BodySerialNumber", - 0xA432: "LensSpecification", - 0xA433: "LensMake", - 0xA434: "LensModel", - 0xA435: "LensSerialNumber", - 0xA460: "CompositeImage", - 0xA461: "CompositeImageCount", - 0xA462: "CompositeImageExposureTimes", - 0xA500: "Gamma", - 0xC4A5: "PrintImageMatching", - 0xC612: "DNGVersion", - 0xC613: "DNGBackwardVersion", - 0xC614: "UniqueCameraModel", - 0xC615: "LocalizedCameraModel", - 0xC616: "CFAPlaneColor", - 0xC617: "CFALayout", - 0xC618: "LinearizationTable", - 0xC619: "BlackLevelRepeatDim", - 0xC61A: "BlackLevel", - 0xC61B: "BlackLevelDeltaH", - 0xC61C: "BlackLevelDeltaV", - 0xC61D: "WhiteLevel", - 0xC61E: "DefaultScale", - 0xC61F: "DefaultCropOrigin", - 0xC620: "DefaultCropSize", - 0xC621: "ColorMatrix1", - 0xC622: "ColorMatrix2", - 0xC623: "CameraCalibration1", - 0xC624: "CameraCalibration2", - 0xC625: "ReductionMatrix1", - 0xC626: "ReductionMatrix2", - 0xC627: "AnalogBalance", - 0xC628: "AsShotNeutral", - 0xC629: "AsShotWhiteXY", - 0xC62A: "BaselineExposure", - 0xC62B: "BaselineNoise", - 0xC62C: "BaselineSharpness", - 0xC62D: "BayerGreenSplit", - 0xC62E: "LinearResponseLimit", - 0xC62F: "CameraSerialNumber", - 0xC630: "LensInfo", - 0xC631: "ChromaBlurRadius", - 0xC632: "AntiAliasStrength", - 0xC633: "ShadowScale", - 0xC634: "DNGPrivateData", - 0xC635: "MakerNoteSafety", - 0xC65A: "CalibrationIlluminant1", - 0xC65B: "CalibrationIlluminant2", - 0xC65C: "BestQualityScale", - 0xC65D: "RawDataUniqueID", - 0xC68B: "OriginalRawFileName", - 0xC68C: "OriginalRawFileData", - 0xC68D: "ActiveArea", - 0xC68E: "MaskedAreas", - 0xC68F: "AsShotICCProfile", - 0xC690: "AsShotPreProfileMatrix", - 0xC691: "CurrentICCProfile", - 0xC692: "CurrentPreProfileMatrix", - 0xC6BF: "ColorimetricReference", - 0xC6F3: "CameraCalibrationSignature", - 0xC6F4: "ProfileCalibrationSignature", - 0xC6F6: "AsShotProfileName", - 0xC6F7: "NoiseReductionApplied", - 0xC6F8: "ProfileName", - 0xC6F9: "ProfileHueSatMapDims", - 0xC6FA: "ProfileHueSatMapData1", - 0xC6FB: "ProfileHueSatMapData2", - 0xC6FC: "ProfileToneCurve", - 0xC6FD: "ProfileEmbedPolicy", - 0xC6FE: "ProfileCopyright", - 0xC714: "ForwardMatrix1", - 0xC715: "ForwardMatrix2", - 0xC716: "PreviewApplicationName", - 0xC717: "PreviewApplicationVersion", - 0xC718: "PreviewSettingsName", - 0xC719: "PreviewSettingsDigest", - 0xC71A: "PreviewColorSpace", - 0xC71B: "PreviewDateTime", - 0xC71C: "RawImageDigest", - 0xC71D: "OriginalRawFileDigest", - 0xC71E: "SubTileBlockSize", - 0xC71F: "RowInterleaveFactor", - 0xC725: "ProfileLookTableDims", - 0xC726: "ProfileLookTableData", - 0xC740: "OpcodeList1", - 0xC741: "OpcodeList2", - 0xC74E: "OpcodeList3", - 0xC761: "NoiseProfile", } -"""Maps EXIF tags to tag names.""" -GPSTAGS = { - 0: "GPSVersionID", - 1: "GPSLatitudeRef", - 2: "GPSLatitude", - 3: "GPSLongitudeRef", - 4: "GPSLongitude", - 5: "GPSAltitudeRef", - 6: "GPSAltitude", - 7: "GPSTimeStamp", - 8: "GPSSatellites", - 9: "GPSStatus", - 10: "GPSMeasureMode", - 11: "GPSDOP", - 12: "GPSSpeedRef", - 13: "GPSSpeed", - 14: "GPSTrackRef", - 15: "GPSTrack", - 16: "GPSImgDirectionRef", - 17: "GPSImgDirection", - 18: "GPSMapDatum", - 19: "GPSDestLatitudeRef", - 20: "GPSDestLatitude", - 21: "GPSDestLongitudeRef", - 22: "GPSDestLongitude", - 23: "GPSDestBearingRef", - 24: "GPSDestBearing", - 25: "GPSDestDistanceRef", - 26: "GPSDestDistance", - 27: "GPSProcessingMethod", - 28: "GPSAreaInformation", - 29: "GPSDateStamp", - 30: "GPSDifferential", - 31: "GPSHPositioningError", -} +class GPS(IntEnum): + GPSVersionID = 0 + GPSLatitudeRef = 1 + GPSLatitude = 2 + GPSLongitudeRef = 3 + GPSLongitude = 4 + GPSAltitudeRef = 5 + GPSAltitude = 6 + GPSTimeStamp = 7 + GPSSatellites = 8 + GPSStatus = 9 + GPSMeasureMode = 10 + GPSDOP = 11 + GPSSpeedRef = 12 + GPSSpeed = 13 + GPSTrackRef = 14 + GPSTrack = 15 + GPSImgDirectionRef = 16 + GPSImgDirection = 17 + GPSMapDatum = 18 + GPSDestLatitudeRef = 19 + GPSDestLatitude = 20 + GPSDestLongitudeRef = 21 + GPSDestLongitude = 22 + GPSDestBearingRef = 23 + GPSDestBearing = 24 + GPSDestDistanceRef = 25 + GPSDestDistance = 26 + GPSProcessingMethod = 27 + GPSAreaInformation = 28 + GPSDateStamp = 29 + GPSDifferential = 30 + GPSHPositioningError = 31 + + """Maps EXIF GPS tags to tag names.""" +GPSTAGS = {i.value: i.name for i in GPS} diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index e13b1779c..908bed9f4 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -15,6 +15,7 @@ # See the README file for information on usage and redistribution. # +import os from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -80,11 +81,19 @@ class FliImageFile(ImageFile.ImageFile): if i16(s, 4) == 0xF1FA: # look for palette chunk - s = self.fp.read(6) - if i16(s, 4) == 11: - self._palette(palette, 2) - elif i16(s, 4) == 4: - self._palette(palette, 0) + number_of_subchunks = i16(s, 6) + chunk_size = None + for _ in range(number_of_subchunks): + if chunk_size is not None: + self.fp.seek(chunk_size - 6, os.SEEK_CUR) + s = self.fp.read(6) + chunk_type = i16(s, 4) + if chunk_type in (4, 11): + self._palette(palette, 2 if chunk_type == 11 else 0) + break + chunk_size = i32(s) + if not chunk_size: + break palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] self.palette = ImagePalette.raw("RGB", b"".join(palette)) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 2e11df54c..dd1b21f2e 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -274,6 +274,8 @@ class GifImageFile(ImageFile.ImageFile): p = self.fp.read(3 << bits) if self._is_palette_needed(p): palette = ImagePalette.raw("RGB", p) + else: + palette = False # image data bits = self.fp.read(1)[0] @@ -298,12 +300,14 @@ class GifImageFile(ImageFile.ImageFile): if self.dispose: self.im.paste(self.dispose, self.dispose_extent) - self._frame_palette = palette or self.global_palette + self._frame_palette = palette if palette is not None else self.global_palette + self._frame_transparency = frame_transparency if frame == 0: if self._frame_palette: - self.mode = ( - "RGB" if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS else "P" - ) + if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + self.mode = "RGBA" if frame_transparency is not None else "RGB" + else: + self.mode = "P" else: self.mode = "L" @@ -313,7 +317,6 @@ class GifImageFile(ImageFile.ImageFile): palette = copy(self.global_palette) self.palette = palette else: - self._frame_transparency = frame_transparency if self.mode == "P": if ( LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY @@ -386,7 +389,8 @@ class GifImageFile(ImageFile.ImageFile): transparency = -1 if frame_transparency is not None: if frame == 0: - self.info["transparency"] = frame_transparency + if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS: + self.info["transparency"] = frame_transparency elif self.mode not in ("RGB", "RGBA"): transparency = frame_transparency self.tile = [ @@ -410,9 +414,9 @@ class GifImageFile(ImageFile.ImageFile): temp_mode = "P" if self._frame_palette else "L" self._prev_im = None if self.__frame == 0: - if "transparency" in self.info: + if self._frame_transparency is not None: self.im = Image.core.fill( - temp_mode, self.size, self.info["transparency"] + temp_mode, self.size, self._frame_transparency ) elif self.mode in ("RGB", "RGBA"): self._prev_im = self.im @@ -429,19 +433,20 @@ class GifImageFile(ImageFile.ImageFile): def load_end(self): if self.__frame == 0: if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: - self.mode = "RGB" - self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + self.mode = "RGBA" + else: + self.mode = "RGB" + self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) return - if self.mode == "P" and self._prev_im: - if self._frame_transparency is not None: - self.im.putpalettealpha(self._frame_transparency, 0) - frame_im = self.im.convert("RGBA") - else: - frame_im = self.im.convert("RGB") + if not self._prev_im: + return + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + frame_im = self.im.convert("RGBA") else: - if not self._prev_im: - return - frame_im = self.im + frame_im = self.im.convert("RGB") frame_im = self._crop(frame_im, self.dispose_extent) self.im = self._prev_im @@ -519,9 +524,8 @@ def _normalize_palette(im, palette, info): used_palette_colors = [] for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) - try: - index = im.palette.colors[source_color] - except KeyError: + index = im.palette.colors.get(source_color) + if index in used_palette_colors: index = None used_palette_colors.append(index) for i, index in enumerate(used_palette_colors): diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 78ccfb9cf..31b0ff469 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -352,7 +352,13 @@ def _save(im, fp, filename): fp.write(b"Lut: 1\r\n") fp.write(b"\000" * (511 - fp.tell()) + b"\032") if im.mode in ["P", "PA"]: - fp.write(im.im.getpalette("RGB", "RGB;L")) # 768 bytes + im_palette = im.im.getpalette("RGB", "RGB;L") + colors = len(im_palette) // 3 + palette = b"" + for i in range(3): + palette += im_palette[colors * i : colors * (i + 1)] + palette += b"\x00" * (256 - colors) + fp.write(palette) # 768 bytes ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 4eb2dead6..7faf0c248 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -679,12 +679,24 @@ class Image: new["shape"] = shape new["typestr"] = typestr new["version"] = 3 - if self.mode == "1": - # Binary images need to be extended from bits to bytes - # See: https://github.com/python-pillow/Pillow/issues/350 - new["data"] = self.tobytes("raw", "L") - else: - new["data"] = self.tobytes() + try: + if self.mode == "1": + # Binary images need to be extended from bits to bytes + # See: https://github.com/python-pillow/Pillow/issues/350 + new["data"] = self.tobytes("raw", "L") + else: + new["data"] = self.tobytes() + except Exception as e: + if not isinstance(e, (MemoryError, RecursionError)): + try: + import numpy + from packaging.version import parse as parse_version + except ImportError: + pass + else: + if parse_version(numpy.__version__) < parse_version("1.23"): + warnings.warn(e) + raise return new def __getstate__(self): @@ -868,7 +880,7 @@ class Image: and the palette can be represented without a palette. The current version supports all possible conversions between - "L", "RGB" and "CMYK." The ``matrix`` argument only supports "L" + "L", "RGB" and "CMYK". The ``matrix`` argument only supports "L" and "RGB". When translating a color image to greyscale (mode "L"), @@ -887,6 +899,9 @@ class Image: this passes the operation to :py:meth:`~PIL.Image.Image.quantize`, and ``dither`` and ``palette`` are ignored. + When converting from "PA", if an "RGBA" palette is present, the alpha + channel from the image will be used instead of the values from the palette. + :param mode: The requested mode. See: :ref:`concept-modes`. :param matrix: An optional conversion matrix. If given, this should be 4- or 12-tuple containing floating point values. @@ -1027,6 +1042,19 @@ class Image: warnings.warn("Couldn't allocate palette entry for transparency") return new + if "LAB" in (self.mode, mode): + other_mode = mode if self.mode == "LAB" else self.mode + if other_mode in ("RGB", "RGBA", "RGBX"): + from . import ImageCms + + srgb = ImageCms.createProfile("sRGB") + lab = ImageCms.createProfile("LAB") + profiles = [lab, srgb] if self.mode == "LAB" else [srgb, lab] + transform = ImageCms.buildTransform( + profiles[0], profiles[1], self.mode, mode + ) + return transform.apply(self) + # colorspace conversion if dither is None: dither = Dither.FLOYDSTEINBERG @@ -1036,7 +1064,10 @@ class Image: except ValueError: try: # normalize source image and try again - im = self.im.convert(getmodebase(self.mode)) + modebase = getmodebase(self.mode) + if modebase == self.mode: + raise + im = self.im.convert(modebase) im = im.convert(mode, dither) except KeyError as e: raise ValueError("illegal conversion") from e @@ -1839,7 +1870,7 @@ class Image: Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for multi-band images. In addition to this, RGB and RGBA tuples are - accepted for P images. + accepted for P and PA images. Note that this method is relatively slow. For more extensive changes, use :py:meth:`~PIL.Image.Image.paste` or the :py:mod:`~PIL.ImageDraw` @@ -1864,12 +1895,17 @@ class Image: return self.pyaccess.putpixel(xy, value) if ( - self.mode == "P" + self.mode in ("P", "PA") and isinstance(value, (list, tuple)) and len(value) in [3, 4] ): - # RGB or RGBA value for a P image + # RGB or RGBA value for a P or PA image + if self.mode == "PA": + alpha = value[3] if len(value) == 4 else 255 + value = value[:3] value = self.palette.getcolor(value, self) + if self.mode == "PA": + value = (value, alpha) return self.im.putpixel(xy, value) def remap_palette(self, dest_map, source_palette=None): @@ -1944,11 +1980,7 @@ class Image: m_im = m_im.convert("L") - # Internally, we require 256 palette entries. - new_palette_bytes = ( - palette_bytes + ((256 * bands) - len(palette_bytes)) * b"\x00" - ) - m_im.putpalette(new_palette_bytes, palette_mode) + m_im.putpalette(palette_bytes, palette_mode) m_im.palette = ImagePalette.ImagePalette(palette_mode, palette=palette_bytes) if "transparency" in self.info: @@ -1984,18 +2016,14 @@ class Image: :param size: The requested size in pixels, as a 2-tuple: (width, height). :param resample: An optional resampling filter. This can be - one of :py:data:`PIL.Image.Resampling.NEAREST`, - :py:data:`PIL.Image.Resampling.BOX`, - :py:data:`PIL.Image.Resampling.BILINEAR`, - :py:data:`PIL.Image.Resampling.HAMMING`, - :py:data:`PIL.Image.Resampling.BICUBIC` or - :py:data:`PIL.Image.Resampling.LANCZOS`. + one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. If the image has mode "1" or "P", it is always set to - :py:data:`PIL.Image.Resampling.NEAREST`. - If the image mode specifies a number of bits, such as "I;16", then the - default filter is :py:data:`PIL.Image.Resampling.NEAREST`. - Otherwise, the default filter is - :py:data:`PIL.Image.Resampling.BICUBIC`. See: :ref:`concept-filters`. + :py:data:`Resampling.NEAREST`. If the image mode specifies a number + of bits, such as "I;16", then the default filter is + :py:data:`Resampling.NEAREST`. Otherwise, the default filter is + :py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`. :param box: An optional 4-tuple of floats providing the source image region to be scaled. The values must be within (0, 0, width, height) rectangle. @@ -2135,12 +2163,12 @@ class Image: :param angle: In degrees counter clockwise. :param resample: An optional resampling filter. This can be - one of :py:data:`PIL.Image.Resampling.NEAREST` (use nearest neighbour), - :py:data:`PIL.Image.BILINEAR` (linear interpolation in a 2x2 - environment), or :py:data:`PIL.Image.Resampling.BICUBIC` - (cubic spline interpolation in a 4x4 environment). - If omitted, or if the image has mode "1" or "P", it is - set to :py:data:`PIL.Image.Resampling.NEAREST`. See :ref:`concept-filters`. + one of :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline + interpolation in a 4x4 environment). If omitted, or if the image has + mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. + See :ref:`concept-filters`. :param expand: Optional expansion flag. If true, expands the output image to make it large enough to hold the entire rotated image. If false or omitted, make the output image the same size as the @@ -2447,14 +2475,11 @@ class Image: :param size: Requested size. :param resample: Optional resampling filter. This can be one - of :py:data:`PIL.Image.Resampling.NEAREST`, - :py:data:`PIL.Image.Resampling.BOX`, - :py:data:`PIL.Image.Resampling.BILINEAR`, - :py:data:`PIL.Image.Resampling.HAMMING`, - :py:data:`PIL.Image.Resampling.BICUBIC` or - :py:data:`PIL.Image.Resampling.LANCZOS`. - If omitted, it defaults to :py:data:`PIL.Image.Resampling.BICUBIC`. - (was :py:data:`PIL.Image.Resampling.NEAREST` prior to version 2.5.0). + of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. + If omitted, it defaults to :py:data:`Resampling.BICUBIC`. + (was :py:data:`Resampling.NEAREST` prior to version 2.5.0). See: :ref:`concept-filters`. :param reducing_gap: Apply optimization by resizing the image in two steps. First, reducing the image by integer times @@ -2473,29 +2498,41 @@ class Image: :returns: None """ - self.load() - x, y = map(math.floor, size) - if x >= self.width and y >= self.height: - return + provided_size = tuple(map(math.floor, size)) - def round_aspect(number, key): - return max(min(math.floor(number), math.ceil(number), key=key), 1) + def preserve_aspect_ratio(): + def round_aspect(number, key): + return max(min(math.floor(number), math.ceil(number), key=key), 1) - # preserve aspect ratio - aspect = self.width / self.height - if x / y >= aspect: - x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) - else: - y = round_aspect( - x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) - ) - size = (x, y) + x, y = provided_size + if x >= self.width and y >= self.height: + return + + aspect = self.width / self.height + if x / y >= aspect: + x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) + else: + y = round_aspect( + x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) + ) + return x, y box = None if reducing_gap is not None: + size = preserve_aspect_ratio() + if size is None: + return + res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) if res is not None: box = res[1] + if box is None: + self.load() + + # load() may have changed the size of the image + size = preserve_aspect_ratio() + if size is None: + return if self.size != size: im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) @@ -2525,11 +2562,11 @@ class Image: :param size: The output size. :param method: The transformation method. This is one of - :py:data:`PIL.Image.Transform.EXTENT` (cut out a rectangular subregion), - :py:data:`PIL.Image.Transform.AFFINE` (affine transform), - :py:data:`PIL.Image.Transform.PERSPECTIVE` (perspective transform), - :py:data:`PIL.Image.Transform.QUAD` (map a quadrilateral to a rectangle), or - :py:data:`PIL.Image.Transform.MESH` (map a number of source quadrilaterals + :py:data:`Transform.EXTENT` (cut out a rectangular subregion), + :py:data:`Transform.AFFINE` (affine transform), + :py:data:`Transform.PERSPECTIVE` (perspective transform), + :py:data:`Transform.QUAD` (map a quadrilateral to a rectangle), or + :py:data:`Transform.MESH` (map a number of source quadrilaterals in one operation). It may also be an :py:class:`~PIL.Image.ImageTransformHandler` @@ -2549,11 +2586,11 @@ class Image: return method, data :param data: Extra data to the transformation method. :param resample: Optional resampling filter. It can be one of - :py:data:`PIL.Image.Resampling.NEAREST` (use nearest neighbour), - :py:data:`PIL.Image.Resampling.BILINEAR` (linear interpolation in a 2x2 - environment), or :py:data:`PIL.Image.BICUBIC` (cubic spline + :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline interpolation in a 4x4 environment). If omitted, or if the image - has mode "1" or "P", it is set to :py:data:`PIL.Image.Resampling.NEAREST`. + has mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. See: :ref:`concept-filters`. :param fill: If ``method`` is an :py:class:`~PIL.Image.ImageTransformHandler` object, this is one of @@ -2680,13 +2717,10 @@ class Image: """ Transpose image (flip or rotate in 90 degree steps) - :param method: One of :py:data:`PIL.Image.Transpose.FLIP_LEFT_RIGHT`, - :py:data:`PIL.Image.Transpose.FLIP_TOP_BOTTOM`, - :py:data:`PIL.Image.Transpose.ROTATE_90`, - :py:data:`PIL.Image.Transpose.ROTATE_180`, - :py:data:`PIL.Image.Transpose.ROTATE_270`, - :py:data:`PIL.Image.Transpose.TRANSPOSE` or - :py:data:`PIL.Image.Transpose.TRANSVERSE`. + :param method: One of :py:data:`Transpose.FLIP_LEFT_RIGHT`, + :py:data:`Transpose.FLIP_TOP_BOTTOM`, :py:data:`Transpose.ROTATE_90`, + :py:data:`Transpose.ROTATE_180`, :py:data:`Transpose.ROTATE_270`, + :py:data:`Transpose.TRANSPOSE` or :py:data:`Transpose.TRANSVERSE`. :returns: Returns a flipped or rotated copy of this image. """ diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e84dafb12..ff94f0ce3 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -87,17 +87,25 @@ class ImageDraw: self.fontmode = "1" else: self.fontmode = "L" # aliasing is okay for other modes - self.fill = 0 + self.fill = False def getfont(self): """ Get the current default font. + To set the default font for this ImageDraw instance:: + + from PIL import ImageDraw, ImageFont + draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + To set the default font for all future ImageDraw instances:: from PIL import ImageDraw, ImageFont ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + If the current default font is ``None``, + it is initialized with ``ImageFont.load_default()``. + :returns: An image font.""" if not self.font: # FIXME: should add a font repository @@ -482,8 +490,8 @@ class ImageDraw: # extract mask and set text alpha color, mask = mask, mask.getband(3) color.fillband(3, (ink >> 24) & 0xFF) - coord2 = coord[0] + mask.size[0], coord[1] + mask.size[1] - self.im.paste(color, coord + coord2, mask) + x, y = (int(c) for c in coord) + self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) else: self.draw.draw_bitmap(coord, mask, ink) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 9f08493c1..f281b9e14 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -192,6 +192,9 @@ class ImageFile(Image.Image): with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + if offset + self.size[1] * args[1] > self.map.size(): + # buffer is not large enough + raise OSError self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args ) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 9386d0086..457e906c8 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -141,6 +141,8 @@ class ImageFont: Use :py:meth:`.getbbox` or :py:meth:`.getlength` instead. + See :ref:`deprecations ` for more information. + Returns width and height (in pixels) of given text. :param text: Text to measure. @@ -338,7 +340,7 @@ class FreeTypeFont: example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -391,7 +393,7 @@ class FreeTypeFont: example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -432,6 +434,8 @@ class FreeTypeFont: 1/64 pixel precision. Use :py:meth:`getbbox()` to get the exact bounding box based on an anchor. + See :ref:`deprecations ` for more information. + Returns width and height (in pixels) of given text if rendered in font with provided direction, features, and language. @@ -456,7 +460,7 @@ class FreeTypeFont: example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -500,6 +504,8 @@ class FreeTypeFont: Use :py:meth:`.ImageDraw.multiline_textbbox` instead. + See :ref:`deprecations ` for more information. + Returns width and height (in pixels) of given text if rendered in font with provided direction, features, and language, while respecting newline characters. @@ -520,7 +526,7 @@ class FreeTypeFont: example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -559,6 +565,8 @@ class FreeTypeFont: Use :py:meth:`.getbbox` instead. + See :ref:`deprecations ` for more information. + Returns the offset of given text. This is the gap between the starting coordinate and the first marking. Note that this gap is included in the result of :py:func:`~PIL.ImageFont.FreeTypeFont.getsize`. @@ -610,7 +618,7 @@ class FreeTypeFont: example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -702,7 +710,7 @@ class FreeTypeFont: example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -795,7 +803,7 @@ class FreeTypeFont: names = self.get_variation_names() if not isinstance(name, bytes): name = name.encode() - index = names.index(name) + index = names.index(name) + 1 if index == getattr(self, "_last_variation_index", None): # When the same name is set twice in a row, @@ -852,6 +860,8 @@ class TransposedFont: .. deprecated:: 9.2.0 Use :py:meth:`.getbbox` or :py:meth:`.getlength` instead. + + See :ref:`deprecations ` for more information. """ deprecate("getsize", 10, "getbbox or getlength") with warnings.catch_warnings(): @@ -945,6 +955,11 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): encoding of any text provided in subsequent operations. :param layout_engine: Which layout engine to use, if available: :data:`.ImageFont.Layout.BASIC` or :data:`.ImageFont.Layout.RAQM`. + If it is available, Raqm layout will be used by default. + Otherwise, basic layout will be used. + + Raqm layout is recommended for all non-English text. If Raqm layout + is not required, basic layout will have better performance. You can check support for Raqm layout using :py:func:`PIL.features.check_feature` with ``feature="raqm"``. diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 0c3f900ca..443c540b6 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -21,7 +21,7 @@ import functools import operator import re -from . import Image +from . import Image, ImagePalette # # helpers @@ -255,11 +255,11 @@ def contain(image, size, method=Image.Resampling.BICUBIC): if im_ratio != dest_ratio: if im_ratio > dest_ratio: - new_height = int(image.height / image.width * size[0]) + new_height = round(image.height / image.width * size[0]) if new_height != size[1]: size = (size[0], new_height) else: - new_width = int(image.width / image.height * size[1]) + new_width = round(image.width / image.height * size[1]) if new_width != size[0]: size = (new_width, size[1]) return image.resize(size, resample=method) @@ -291,11 +291,13 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5 out = resized else: out = Image.new(image.mode, size, color) + if resized.palette: + out.putpalette(resized.getpalette()) if resized.width != size[0]: - x = int((size[0] - resized.width) * max(0, min(centering[0], 1))) + x = round((size[0] - resized.width) * max(0, min(centering[0], 1))) out.paste(resized, (x, 0)) else: - y = int((size[1] - resized.height) * max(0, min(centering[1], 1))) + y = round((size[1] - resized.height) * max(0, min(centering[1], 1))) out.paste(resized, (0, y)) return out @@ -396,9 +398,8 @@ def expand(image, border=0, fill=0): width = left + image.size[0] + right height = top + image.size[1] + bottom color = _color(fill, image.mode) - if image.mode == "P" and image.palette: - image.load() - palette = image.palette.copy() + if image.palette: + palette = ImagePalette.ImagePalette(palette=image.getpalette()) if isinstance(color, tuple): color = palette.getcolor(color) else: diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 853147ac2..fe76c86f4 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -50,15 +50,24 @@ class ImagePalette: @palette.setter def palette(self, palette): + self._colors = None self._palette = palette - mode_len = len(self.mode) - self.colors = {} - for i in range(0, len(self.palette), mode_len): - color = tuple(self.palette[i : i + mode_len]) - if color in self.colors: - continue - self.colors[color] = i // mode_len + @property + def colors(self): + if self._colors is None: + mode_len = len(self.mode) + self._colors = {} + for i in range(0, len(self.palette), mode_len): + color = tuple(self.palette[i : i + mode_len]) + if color in self._colors: + continue + self._colors[color] = i // mode_len + return self._colors + + @colors.setter + def colors(self, colors): + self._colors = colors def copy(self): new = ImagePalette() @@ -106,7 +115,11 @@ class ImagePalette: raise ValueError("palette contains raw palette data") if isinstance(color, tuple): if self.mode == "RGB": - if len(color) == 4 and color[3] == 255: + if len(color) == 4: + if color[3] != 255: + raise ValueError( + "cannot add non-opaque RGBA color to RGB palette" + ) color = color[:3] elif self.mode == "RGBA": if len(color) == 3: diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index 9f9a551fb..76f42a307 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -136,7 +136,7 @@ class WindowsViewer(Viewer): """The default viewer on Windows is the default system application for PNG files.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} def get_command(self, file, **options): return ( @@ -154,7 +154,7 @@ class MacViewer(Viewer): """The default viewer on macOS using ``Preview.app``.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} def get_command(self, file, **options): # on darwin open returns immediately resulting in the temp @@ -197,7 +197,7 @@ if sys.platform == "darwin": class UnixViewer(Viewer): format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} def get_command(self, file, **options): command = self.get_command_ex(file, **options)[0] diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index c2c4d774c..949cf1fbf 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -68,21 +68,7 @@ def _pyimagingtkcall(command, photo, id): # may raise an error if it cannot attach to Tkinter from . import _imagingtk - try: - if hasattr(tk, "interp"): - # Required for PyPy, which always has CFFI installed - from cffi import FFI - - ffi = FFI() - - # PyPy is using an FFI CDATA element - # (Pdb) self.tk.interp - # - _imagingtk.tkinit(int(ffi.cast("uintptr_t", tk.interp)), 1) - else: - _imagingtk.tkinit(tk.interpaddr(), 1) - except AttributeError: - _imagingtk.tkinit(id(tk), 0) + _imagingtk.tkinit(tk.interpaddr()) tk.call(command, photo, id) @@ -121,6 +107,7 @@ class PhotoImage: mode = image.mode if mode == "P": # palette mapped data + image.apply_transparency() image.load() try: mode = image.palette.mode diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index 5790acdaf..dc7078012 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -39,15 +39,19 @@ class ImtImageFile(ImageFile.ImageFile): # Quick rejection: if there's not a LF among the first # 100 bytes, this is (probably) not a text header. - if b"\n" not in self.fp.read(100): + buffer = self.fp.read(100) + if b"\n" not in buffer: raise SyntaxError("not an IM file") - self.fp.seek(0) xsize = ysize = 0 while True: - s = self.fp.read(1) + if buffer: + s = buffer[:1] + buffer = buffer[1:] + else: + s = self.fp.read(1) if not s: break @@ -55,7 +59,12 @@ class ImtImageFile(ImageFile.ImageFile): # image data begins self.tile = [ - ("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1)) + ( + "raw", + (0, 0) + self.size, + self.fp.tell() - len(buffer), + (self.mode, 0, 1), + ) ] break @@ -63,8 +72,11 @@ class ImtImageFile(ImageFile.ImageFile): else: # read key/value pair - # FIXME: dangerous, may read whole file - s = s + self.fp.readline() + if b"\n" not in buffer: + buffer += self.fp.read(100) + lines = buffer.split(b"\n") + s += lines.pop(0) + buffer = b"\n".join(lines) if len(s) == 1 or len(s) > 100: break if s[0] == ord(b"*"): @@ -74,13 +86,13 @@ class ImtImageFile(ImageFile.ImageFile): if not m: break k, v = m.group(1, 2) - if k == "width": + if k == b"width": xsize = int(v) self._size = xsize, ysize - elif k == "height": + elif k == b"height": ysize = int(v) self._size = xsize, ysize - elif k == "pixel" and v == "n8": + elif k == b"pixel" and v == b"n8": self.mode = "L" diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 181a05b8d..404759a7f 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -25,7 +25,7 @@ import math import os import time -from . import Image, ImageFile, ImageSequence, PdfParser, __version__ +from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features # # -------------------------------------------------------------------- @@ -130,20 +130,23 @@ def _save(im, fp, filename, save_all=False): width, height = im.size if im.mode == "1": - filter = "CCITTFaxDecode" - bits = 1 - params = PdfParser.PdfArray( - [ - PdfParser.PdfDict( - { - "K": -1, - "BlackIs1": True, - "Columns": width, - "Rows": height, - } - ) - ] - ) + if features.check("libtiff"): + filter = "CCITTFaxDecode" + bits = 1 + params = PdfParser.PdfArray( + [ + PdfParser.PdfDict( + { + "K": -1, + "BlackIs1": True, + "Columns": width, + "Rows": height, + } + ) + ] + ) + else: + filter = "DCTDecode" colorspace = PdfParser.PdfName("DeviceGray") procset = "ImageB" # grayscale elif im.mode == "L": diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 442c65e6f..2c53be109 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -189,7 +189,7 @@ class ChunkStream: self.close() def close(self): - self.queue = self.crc = self.fp = None + self.queue = self.fp = None def push(self, cid, pos, length): @@ -224,7 +224,7 @@ class ChunkStream: ) from e def crc_skip(self, cid, data): - """Read checksum. Used if the C module is not present""" + """Read checksum""" self.fp.read(4) @@ -1089,28 +1089,28 @@ class _fdat: self.seq_num += 1 -def _write_multiple_frames(im, fp, chunk, rawmode): - default_image = im.encoderinfo.get("default_image", im.info.get("default_image")) +def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images): duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) if default_image: - chain = itertools.chain(im.encoderinfo.get("append_images", [])) + chain = itertools.chain(append_images) else: - chain = itertools.chain([im], im.encoderinfo.get("append_images", [])) + chain = itertools.chain([im], append_images) im_frames = [] frame_count = 0 for im_seq in chain: for im_frame in ImageSequence.Iterator(im_seq): - im_frame = im_frame.copy() - if im_frame.mode != im.mode: - if im.mode == "P": - im_frame = im_frame.convert(im.mode, palette=im.palette) + if im_frame.mode == rawmode: + im_frame = im_frame.copy() + else: + if rawmode == "P": + im_frame = im_frame.convert(rawmode, palette=im.palette) else: - im_frame = im_frame.convert(im.mode) + im_frame = im_frame.convert(rawmode) encoderinfo = im.encoderinfo.copy() if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] @@ -1128,7 +1128,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode): prev_disposal = Disposal.OP_BACKGROUND if prev_disposal == Disposal.OP_BACKGROUND: - base_im = previous["im"] + base_im = previous["im"].copy() dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) bbox = previous["bbox"] if bbox: @@ -1221,7 +1221,26 @@ def _save_all(im, fp, filename): def _save(im, fp, filename, chunk=putchunk, save_all=False): # save an image to disk (called by the save method) - mode = im.mode + if save_all: + default_image = im.encoderinfo.get( + "default_image", im.info.get("default_image") + ) + modes = set() + append_images = im.encoderinfo.get("append_images", []) + if default_image: + chain = itertools.chain(append_images) + else: + chain = itertools.chain([im], append_images) + for im_seq in chain: + for im_frame in ImageSequence.Iterator(im_seq): + modes.add(im_frame.mode) + for mode in ("RGBA", "RGB", "P"): + if mode in modes: + break + else: + mode = modes.pop() + else: + mode = im.mode if mode == "P": @@ -1373,7 +1392,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, b"eXIf", exif) if save_all: - _write_multiple_frames(im, fp, chunk, rawmode) + _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) else: ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 2a48c53f7..9a2ec48fc 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -58,7 +58,7 @@ class PyAccess: # Keep pointer to im object to prevent dereferencing. self._im = img.im - if self._im.mode == "P": + if self._im.mode in ("P", "PA"): self._palette = img.palette # Debugging is polluting test traces, only useful here @@ -89,12 +89,17 @@ class PyAccess: (x, y) = self.check_xy((x, y)) if ( - self._im.mode == "P" + self._im.mode in ("P", "PA") and isinstance(color, (list, tuple)) and len(color) in [3, 4] ): - # RGB or RGBA value for a P image + # RGB or RGBA value for a P or PA image + if self._im.mode == "PA": + alpha = color[3] if len(color) == 4 else 255 + color = color[:3] color = self._palette.getcolor(color, self._img) + if self._im.mode == "PA": + color = (color, alpha) return self.set_pixel(x, y, color) diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 59b89e988..cd454b755 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -193,9 +193,10 @@ def _save(im, fp, filename): warnings.warn("id_section has been trimmed to 255 characters") if colormaptype: - colormapfirst, colormaplength, colormapentry = 0, 256, 24 + palette = im.im.getpalette("RGB", "BGR") + colormaplength, colormapentry = len(palette) // 3, 24 else: - colormapfirst, colormaplength, colormapentry = 0, 0, 0 + colormaplength, colormapentry = 0, 0 if im.mode in ("LA", "RGBA"): flags = 8 @@ -210,7 +211,7 @@ def _save(im, fp, filename): o8(id_len) + o8(colormaptype) + o8(imagetype) - + o16(colormapfirst) + + o16(0) # colormapfirst + o16(colormaplength) + o8(colormapentry) + o16(0) @@ -225,7 +226,7 @@ def _save(im, fp, filename): fp.write(id_section) if colormaptype: - fp.write(im.im.getpalette("RGB", "BGR")) + fp.write(palette) if rle: ImageFile._save( diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index da33cc5a5..04a63bd2b 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -173,6 +173,7 @@ OPEN_INFO = { (II, 1, (1,), 2, (8,), ()): ("L", "L;R"), (MM, 1, (1,), 2, (8,), ()): ("L", "L;R"), (II, 1, (1,), 1, (12,), ()): ("I;16", "I;12"), + (II, 0, (1,), 1, (16,), ()): ("I;16", "I;16"), (II, 1, (1,), 1, (16,), ()): ("I;16", "I;16"), (MM, 1, (1,), 1, (16,), ()): ("I;16B", "I;16B"), (II, 1, (1,), 2, (16,), ()): ("I;16", "I;16R"), @@ -727,7 +728,9 @@ class ImageFileDirectory_v2(MutableMapping): @_register_writer(2) def write_string(self, value): # remerge of https://github.com/python-pillow/Pillow/pull/1416 - return b"" + value.encode("ascii", "replace") + b"\0" + if not isinstance(value, bytes): + value = value.encode("ascii", "replace") + return value + b"\0" @_register_loader(5, 8) def load_rational(self, data, legacy_api=True): @@ -1146,6 +1149,39 @@ class TiffImageFile(ImageFile.ImageFile): """Return the current frame number""" return self.__frame + def get_child_images(self): + if SUBIFD not in self.tag_v2: + return [] + child_images = [] + exif = self.getexif() + offset = None + for im_offset in self.tag_v2[SUBIFD]: + # reset buffered io handle in case fp + # was passed to libtiff, invalidating the buffer + current_offset = self._fp.tell() + if offset is None: + offset = current_offset + + fp = self._fp + ifd = exif._get_ifd_dict(im_offset) + jpegInterchangeFormat = ifd.get(513) + if jpegInterchangeFormat is not None: + fp.seek(jpegInterchangeFormat) + jpeg_data = fp.read(ifd.get(514)) + + fp = io.BytesIO(jpeg_data) + + with Image.open(fp) as im: + if jpegInterchangeFormat is None: + im._frame_pos = [im_offset] + im._seek(0) + im.load() + child_images.append(im) + + if offset is not None: + self._fp.seek(offset) + return child_images + def getxmp(self): """ Returns a dictionary containing the XMP tags. @@ -1153,7 +1189,7 @@ class TiffImageFile(ImageFile.ImageFile): :returns: XMP tags in a dictionary. """ - return self._getxmp(self.tag_v2[700]) if 700 in self.tag_v2 else {} + return self._getxmp(self.tag_v2[XMP]) if XMP in self.tag_v2 else {} def get_photoshop_blocks(self): """ @@ -1328,7 +1364,7 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug(f"- photometric_interpretation: {photo}") logger.debug(f"- planar_configuration: {self._planar_configuration}") logger.debug(f"- fill_order: {fillorder}") - logger.debug(f"- YCbCr subsampling: {self.tag.get(530)}") + logger.debug(f"- YCbCr subsampling: {self.tag.get(YCBCRSUBSAMPLING)}") # size xsize = int(self.tag_v2.get(IMAGEWIDTH)) @@ -1469,8 +1505,8 @@ class TiffImageFile(ImageFile.ImageFile): else: # tiled image offsets = self.tag_v2[TILEOFFSETS] - w = self.tag_v2.get(322) - h = self.tag_v2.get(323) + w = self.tag_v2.get(TILEWIDTH) + h = self.tag_v2.get(TILELENGTH) for offset in offsets: if x + w > xsize: diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index e3094b4db..9b5277138 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -160,6 +160,7 @@ TAGS_V2 = { 323: ("TileLength", LONG, 1), 324: ("TileOffsets", LONG, 0), 325: ("TileByteCounts", LONG, 0), + 330: ("SubIFDs", LONG, 0), 332: ("InkSet", SHORT, 1), 333: ("InkNames", ASCII, 1), 334: ("NumberOfInks", SHORT, 1), @@ -231,7 +232,39 @@ TAGS_V2_GROUPS = { 41730: ("CFAPattern", UNDEFINED, 1), }, # GPSInfoIFD - 34853: {}, + 34853: { + 0: ("GPSVersionID", BYTE, 4), + 1: ("GPSLatitudeRef", ASCII, 2), + 2: ("GPSLatitude", RATIONAL, 3), + 3: ("GPSLongitudeRef", ASCII, 2), + 4: ("GPSLongitude", RATIONAL, 3), + 5: ("GPSAltitudeRef", BYTE, 1), + 6: ("GPSAltitude", RATIONAL, 1), + 7: ("GPSTimeStamp", RATIONAL, 3), + 8: ("GPSSatellites", ASCII, 0), + 9: ("GPSStatus", ASCII, 2), + 10: ("GPSMeasureMode", ASCII, 2), + 11: ("GPSDOP", RATIONAL, 1), + 12: ("GPSSpeedRef", ASCII, 2), + 13: ("GPSSpeed", RATIONAL, 1), + 14: ("GPSTrackRef", ASCII, 2), + 15: ("GPSTrack", RATIONAL, 1), + 16: ("GPSImgDirectionRef", ASCII, 2), + 17: ("GPSImgDirection", RATIONAL, 1), + 18: ("GPSMapDatum", ASCII, 0), + 19: ("GPSDestLatitudeRef", ASCII, 2), + 20: ("GPSDestLatitude", RATIONAL, 3), + 21: ("GPSDestLongitudeRef", ASCII, 2), + 22: ("GPSDestLongitude", RATIONAL, 3), + 23: ("GPSDestBearingRef", ASCII, 2), + 24: ("GPSDestBearing", RATIONAL, 1), + 25: ("GPSDestDistanceRef", ASCII, 2), + 26: ("GPSDestDistance", RATIONAL, 1), + 27: ("GPSProcessingMethod", UNDEFINED, 0), + 28: ("GPSAreaInformation", UNDEFINED, 0), + 29: ("GPSDateStamp", ASCII, 11), + 30: ("GPSDifferential", SHORT, 1), + }, # InteroperabilityIFD 40965: {1: ("InteropIndex", ASCII, 1), 2: ("InteropVersion", UNDEFINED, 1)}, } diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index c1f4b730f..5eaeb10cc 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -311,9 +311,11 @@ def _save(im, fp, filename): lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) icc_profile = im.encoderinfo.get("icc_profile") or "" - exif = im.encoderinfo.get("exif", "") + exif = im.encoderinfo.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] xmp = im.encoderinfo.get("xmp", "") method = im.encoderinfo.get("method", 4) diff --git a/src/_imagingft.c b/src/_imagingft.c index 8f19b763c..4c3a37fb2 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1179,7 +1179,7 @@ font_setvaraxes(FontObject *self, PyObject *args) { } num_coords = PyObject_Length(axes); - coords = malloc(2 * sizeof(coords)); + coords = (FT_Fixed*)malloc(num_coords * sizeof(FT_Fixed)); if (coords == NULL) { return PyErr_NoMemory(); } diff --git a/src/_imagingtk.c b/src/_imagingtk.c index 3f154166b..b9273b0b8 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -23,33 +23,16 @@ TkImaging_Init(Tcl_Interp *interp); extern int load_tkinter_funcs(void); -/* copied from _tkinter.c (this isn't as bad as it may seem: for new - versions, we use _tkinter's interpaddr hook instead, and all older - versions use this structure layout) */ - -typedef struct { - PyObject_HEAD Tcl_Interp *interp; -} TkappObject; - static PyObject * _tkinit(PyObject *self, PyObject *args) { Tcl_Interp *interp; PyObject *arg; - int is_interp; - if (!PyArg_ParseTuple(args, "Oi", &arg, &is_interp)) { + if (!PyArg_ParseTuple(args, "O", &arg)) { return NULL; } - if (is_interp) { - interp = (Tcl_Interp *)PyLong_AsVoidPtr(arg); - } else { - TkappObject *app; - /* Do it the hard way. This will break if the TkappObject - layout changes */ - app = (TkappObject *)PyLong_AsVoidPtr(arg); - interp = app->interp; - } + interp = (Tcl_Interp *)PyLong_AsVoidPtr(arg); /* This will bomb if interp is invalid... */ TkImaging_Init(interp); diff --git a/src/decode.c b/src/decode.c index cb018a4e7..7a9b956c5 100644 --- a/src/decode.c +++ b/src/decode.c @@ -376,11 +376,8 @@ PyImaging_BcnDecoderNew(PyObject *self, PyObject *args) { actual = "L"; break; case 5: /* BC5: 2-channel 8-bit via 2 BC3 alpha blocks */ - actual = "RGB"; - break; case 6: /* BC6: 3-channel 16-bit float */ - /* TODO: support 4-channel floating point images */ - actual = "RGBAF"; + actual = "RGB"; break; default: PyErr_SetString(PyExc_ValueError, "block compression type unknown"); diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index 22b36eb7a..a57b74b61 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -23,10 +23,6 @@ typedef struct { UINT8 l; } lum; -typedef struct { - FLOAT32 r, g, b; -} rgb32f; - typedef struct { UINT16 c0, c1; UINT32 lut; @@ -536,53 +532,53 @@ static const bc6_mode_info bc6_modes[] = { /* Table.F, encoded as a sequence of bit indices */ static const UINT8 bc6_bit_packings[][75] = { - {116, 132, 176, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, + {116, 132, 180, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 48, 49, 50, 51, 52, 164, 112, 113, 114, 115, 64, 65, - 66, 67, 68, 172, 160, 161, 162, 163, 80, 81, 82, 83, 84, 173, 128, - 129, 130, 131, 96, 97, 98, 99, 100, 174, 144, 145, 146, 147, 148, 175}, - {117, 164, 165, 0, 1, 2, 3, 4, 5, 6, 172, 173, 132, 16, 17, - 18, 19, 20, 21, 22, 133, 174, 116, 32, 33, 34, 35, 36, 37, 38, - 175, 177, 176, 48, 49, 50, 51, 52, 53, 112, 113, 114, 115, 64, 65, + 66, 67, 68, 176, 160, 161, 162, 163, 80, 81, 82, 83, 84, 177, 128, + 129, 130, 131, 96, 97, 98, 99, 100, 178, 144, 145, 146, 147, 148, 179}, + {117, 164, 165, 0, 1, 2, 3, 4, 5, 6, 176, 177, 132, 16, 17, + 18, 19, 20, 21, 22, 133, 178, 116, 32, 33, 34, 35, 36, 37, 38, + 179, 181, 180, 48, 49, 50, 51, 52, 53, 112, 113, 114, 115, 64, 65, 66, 67, 68, 69, 160, 161, 162, 163, 80, 81, 82, 83, 84, 85, 128, 129, 130, 131, 96, 97, 98, 99, 100, 101, 144, 145, 146, 147, 148, 149}, {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 48, 49, 50, 51, 52, 10, 112, 113, 114, 115, 64, 65, 66, 67, 26, - 172, 160, 161, 162, 163, 80, 81, 82, 83, 42, 173, 128, 129, 130, 131, - 96, 97, 98, 99, 100, 174, 144, 145, 146, 147, 148, 175}, + 176, 160, 161, 162, 163, 80, 81, 82, 83, 42, 177, 128, 129, 130, 131, + 96, 97, 98, 99, 100, 178, 144, 145, 146, 147, 148, 179}, {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 48, 49, 50, 51, 10, 164, 112, 113, 114, 115, 64, 65, 66, 67, 68, - 26, 160, 161, 162, 163, 80, 81, 82, 83, 42, 173, 128, 129, 130, 131, - 96, 97, 98, 99, 172, 174, 144, 145, 146, 147, 116, 175}, + 26, 160, 161, 162, 163, 80, 81, 82, 83, 42, 177, 128, 129, 130, 131, + 96, 97, 98, 99, 176, 178, 144, 145, 146, 147, 116, 179}, {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 48, 49, 50, 51, 10, 132, 112, 113, 114, 115, 64, 65, 66, 67, 26, - 172, 160, 161, 162, 163, 80, 81, 82, 83, 84, 42, 128, 129, 130, 131, - 96, 97, 98, 99, 173, 174, 144, 145, 146, 147, 176, 175}, + 176, 160, 161, 162, 163, 80, 81, 82, 83, 84, 42, 128, 129, 130, 131, + 96, 97, 98, 99, 177, 178, 144, 145, 146, 147, 180, 179}, {0, 1, 2, 3, 4, 5, 6, 7, 8, 132, 16, 17, 18, 19, 20, - 21, 22, 23, 24, 116, 32, 33, 34, 35, 36, 37, 38, 39, 40, 176, + 21, 22, 23, 24, 116, 32, 33, 34, 35, 36, 37, 38, 39, 40, 180, 48, 49, 50, 51, 52, 164, 112, 113, 114, 115, 64, 65, 66, 67, 68, - 172, 160, 161, 162, 163, 80, 81, 82, 83, 84, 173, 128, 129, 130, 131, - 96, 97, 98, 99, 100, 174, 144, 145, 146, 147, 148, 175}, + 176, 160, 161, 162, 163, 80, 81, 82, 83, 84, 177, 128, 129, 130, 131, + 96, 97, 98, 99, 100, 178, 144, 145, 146, 147, 148, 179}, {0, 1, 2, 3, 4, 5, 6, 7, 164, 132, 16, 17, 18, 19, 20, - 21, 22, 23, 174, 116, 32, 33, 34, 35, 36, 37, 38, 39, 175, 176, + 21, 22, 23, 178, 116, 32, 33, 34, 35, 36, 37, 38, 39, 179, 180, 48, 49, 50, 51, 52, 53, 112, 113, 114, 115, 64, 65, 66, 67, 68, - 172, 160, 161, 162, 163, 80, 81, 82, 83, 84, 173, 128, 129, 130, 131, + 176, 160, 161, 162, 163, 80, 81, 82, 83, 84, 177, 128, 129, 130, 131, 96, 97, 98, 99, 100, 101, 144, 145, 146, 147, 148, 149}, - {0, 1, 2, 3, 4, 5, 6, 7, 172, 132, 16, 17, 18, 19, 20, - 21, 22, 23, 117, 116, 32, 33, 34, 35, 36, 37, 38, 39, 165, 176, + {0, 1, 2, 3, 4, 5, 6, 7, 176, 132, 16, 17, 18, 19, 20, + 21, 22, 23, 117, 116, 32, 33, 34, 35, 36, 37, 38, 39, 165, 180, 48, 49, 50, 51, 52, 164, 112, 113, 114, 115, 64, 65, 66, 67, 68, - 69, 160, 161, 162, 163, 80, 81, 82, 83, 84, 173, 128, 129, 130, 131, - 96, 97, 98, 99, 100, 174, 144, 145, 146, 147, 148, 175}, - {0, 1, 2, 3, 4, 5, 6, 7, 173, 132, 16, 17, 18, 19, 20, - 21, 22, 23, 133, 116, 32, 33, 34, 35, 36, 37, 38, 39, 177, 176, + 69, 160, 161, 162, 163, 80, 81, 82, 83, 84, 177, 128, 129, 130, 131, + 96, 97, 98, 99, 100, 178, 144, 145, 146, 147, 148, 179}, + {0, 1, 2, 3, 4, 5, 6, 7, 177, 132, 16, 17, 18, 19, 20, + 21, 22, 23, 133, 116, 32, 33, 34, 35, 36, 37, 38, 39, 181, 180, 48, 49, 50, 51, 52, 164, 112, 113, 114, 115, 64, 65, 66, 67, 68, - 172, 160, 161, 162, 163, 80, 81, 82, 83, 84, 85, 128, 129, 130, 131, - 96, 97, 98, 99, 100, 174, 144, 145, 146, 147, 148, 175}, - {0, 1, 2, 3, 4, 5, 164, 172, 173, 132, 16, 17, 18, 19, 20, - 21, 117, 133, 174, 116, 32, 33, 34, 35, 36, 37, 165, 175, 177, 176, + 176, 160, 161, 162, 163, 80, 81, 82, 83, 84, 85, 128, 129, 130, 131, + 96, 97, 98, 99, 100, 178, 144, 145, 146, 147, 148, 179}, + {0, 1, 2, 3, 4, 5, 164, 176, 177, 132, 16, 17, 18, 19, 20, + 21, 117, 133, 178, 116, 32, 33, 34, 35, 36, 37, 165, 179, 181, 180, 48, 49, 50, 51, 52, 53, 112, 113, 114, 115, 64, 65, 66, 67, 68, 69, 160, 161, 162, 163, 80, 81, 82, 83, 84, 85, 128, 129, 130, 131, 96, 97, 98, 99, 100, 101, 144, 145, 146, 147, 148, 149}, @@ -681,20 +677,31 @@ bc6_finalize(int v, int sign) { } } +static UINT8 +bc6_clamp(float value) { + if (value < 0.0f) { + return 0; + } else if (value > 1.0f) { + return 255; + } else { + return (UINT8) (value * 255.0f); + } +} + static void -bc6_lerp(rgb32f *col, int *e0, int *e1, int s, int sign) { +bc6_lerp(rgba *col, int *e0, int *e1, int s, int sign) { int r, g, b; int t = 64 - s; r = (e0[0] * t + e1[0] * s) >> 6; g = (e0[1] * t + e1[1] * s) >> 6; b = (e0[2] * t + e1[2] * s) >> 6; - col->r = bc6_finalize(r, sign); - col->g = bc6_finalize(g, sign); - col->b = bc6_finalize(b, sign); + col->r = bc6_clamp(bc6_finalize(r, sign)); + col->g = bc6_clamp(bc6_finalize(g, sign)); + col->b = bc6_clamp(bc6_finalize(b, sign)); } static void -decode_bc6_block(rgb32f *col, const UINT8 *src, int sign) { +decode_bc6_block(rgba *col, const UINT8 *src, int sign) { UINT16 endpoints[12]; /* storage for r0, g0, b0, r1, ... */ int ueps[12]; int i, i0, ib2, di, dw, mask, numep, s; @@ -744,21 +751,16 @@ decode_bc6_block(rgb32f *col, const UINT8 *src, int sign) { } if (sign || info->tr) { /* sign-extend e1,2,3 if signed or deltas */ for (i = 3; i < numep; i += 3) { - bc6_sign_extend(&endpoints[i + 0], info->rb); + bc6_sign_extend(&endpoints[i], info->rb); bc6_sign_extend(&endpoints[i + 1], info->gb); bc6_sign_extend(&endpoints[i + 2], info->bb); } } if (info->tr) { /* apply deltas */ - for (i = 3; i < numep; i++) { + for (i = 3; i < numep; i += 3) { endpoints[i] = (endpoints[i] + endpoints[0]) & mask; - } - if (sign) { - for (i = 3; i < numep; i += 3) { - bc6_sign_extend(&endpoints[i + 0], info->rb); - bc6_sign_extend(&endpoints[i + 1], info->gb); - bc6_sign_extend(&endpoints[i + 2], info->bb); - } + endpoints[i + 1] = (endpoints[i + 1] + endpoints[1]) & mask; + endpoints[i + 2] = (endpoints[i + 2] + endpoints[2]) & mask; } } for (i = 0; i < numep; i++) { @@ -862,8 +864,8 @@ decode_bcn( break; case 6: while (bytes >= 16) { - rgb32f col[16]; - decode_bc6_block(col, ptr, (state->state >> 4) & 1); + rgba col[16]; + decode_bc6_block(col, ptr, strcmp(pixel_format, "BC6HS") == 0 ? 1 : 0); put_block(im, state, (const char *)col, sizeof(col[0]), C); ptr += 16; bytes -= 16; diff --git a/src/libImaging/ColorLUT.c b/src/libImaging/ColorLUT.c index fd6e268b5..aee7cda06 100644 --- a/src/libImaging/ColorLUT.c +++ b/src/libImaging/ColorLUT.c @@ -7,8 +7,8 @@ #define PRECISION_BITS (16 - 8 - 2) #define PRECISION_ROUNDING (1 << (PRECISION_BITS - 1)) -/* 8 — scales are multiplied on byte. - 6 — max index in the table +/* 8 - scales are multiplied on byte. + 6 - max index in the table (max size is 65, but index 64 is not reachable) */ #define SCALE_BITS (32 - 8 - 6) #define SCALE_MASK ((1 << SCALE_BITS) - 1) @@ -44,14 +44,14 @@ table_index3D(int index1D, int index2D, int index3D, int size1D, int size1D_2D) Transforms colors of imIn using provided 3D lookup table and puts the result in imOut. Returns imOut on success or 0 on error. - imOut, imIn — images, should be the same size and may be the same image. + imOut, imIn - images, should be the same size and may be the same image. Should have 3 or 4 channels. - table_channels — number of channels in the lookup table, 3 or 4. + table_channels - number of channels in the lookup table, 3 or 4. Should be less or equal than number of channels in imOut image; - size1D, size_2D and size3D — dimensions of provided table; - table — flat table, - array with table_channels × size1D × size2D × size3D elements, - where channels are changed first, then 1D, then​ 2D, then 3D. + size1D, size_2D and size3D - dimensions of provided table; + table - flat table, + array with table_channels * size1D * size2D * size3D elements, + where channels are changed first, then 1D, then 2D, then 3D. Each element is signed 16-bit int where 0 is lowest output value and 255 << PRECISION_BITS (16320) is highest value. */ diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index f0d42f7ff..bdc680be4 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1243,7 +1243,7 @@ frompalette(Imaging imOut, Imaging imIn, const char *mode) { if (!imOut) { return NULL; } - if (strcmp(mode, "P") == 0) { + if (strcmp(mode, "P") == 0 || strcmp(mode, "PA") == 0) { ImagingPaletteDelete(imOut->palette); imOut->palette = ImagingPaletteDuplicate(imIn->palette); } diff --git a/src/libImaging/Matrix.c b/src/libImaging/Matrix.c index 137ed242a..182eb62a7 100644 --- a/src/libImaging/Matrix.c +++ b/src/libImaging/Matrix.c @@ -21,6 +21,7 @@ Imaging ImagingConvertMatrix(Imaging im, const char *mode, float m[]) { Imaging imOut; int x, y; + ImagingSectionCookie cookie; /* Assume there's enough data in the buffer */ if (!im) { @@ -33,6 +34,7 @@ ImagingConvertMatrix(Imaging im, const char *mode, float m[]) { return NULL; } + ImagingSectionEnter(&cookie); for (y = 0; y < im->ysize; y++) { UINT8 *in = (UINT8 *)im->image[y]; UINT8 *out = (UINT8 *)imOut->image[y]; @@ -43,6 +45,7 @@ ImagingConvertMatrix(Imaging im, const char *mode, float m[]) { in += 4; } } + ImagingSectionLeave(&cookie); } else if (strlen(mode) == 3 && im->bands == 3) { imOut = ImagingNewDirty(mode, im->xsize, im->ysize); @@ -54,6 +57,7 @@ ImagingConvertMatrix(Imaging im, const char *mode, float m[]) { UINT8 *in = (UINT8 *)im->image[y]; UINT8 *out = (UINT8 *)imOut->image[y]; + ImagingSectionEnter(&cookie); for (x = 0; x < im->xsize; x++) { float v0 = m[0] * in[0] + m[1] * in[1] + m[2] * in[2] + m[3] + 0.5; float v1 = m[4] * in[0] + m[5] * in[1] + m[6] * in[2] + m[7] + 0.5; @@ -64,6 +68,7 @@ ImagingConvertMatrix(Imaging im, const char *mode, float m[]) { in += 4; out += 4; } + ImagingSectionLeave(&cookie); } } else { return (Imaging)ImagingError_ModeError(); diff --git a/src/libImaging/Paste.c b/src/libImaging/Paste.c index fafd8141e..acf5202e5 100644 --- a/src/libImaging/Paste.c +++ b/src/libImaging/Paste.c @@ -432,18 +432,18 @@ fill_mask_L( } } else { + int alpha_channel = strcmp(imOut->mode, "RGBa") == 0 || + strcmp(imOut->mode, "RGBA") == 0 || + strcmp(imOut->mode, "La") == 0 || + strcmp(imOut->mode, "LA") == 0 || + strcmp(imOut->mode, "PA") == 0; for (y = 0; y < ysize; y++) { UINT8 *out = (UINT8 *)imOut->image[y + dy] + dx * pixelsize; UINT8 *mask = (UINT8 *)imMask->image[y + sy] + sx; for (x = 0; x < xsize; x++) { for (i = 0; i < pixelsize; i++) { UINT8 channel_mask = *mask; - if ((strcmp(imOut->mode, "RGBa") == 0 || - strcmp(imOut->mode, "RGBA") == 0 || - strcmp(imOut->mode, "La") == 0 || - strcmp(imOut->mode, "LA") == 0 || - strcmp(imOut->mode, "PA") == 0) && - i != 3 && channel_mask != 0) { + if (alpha_channel && i != 3 && channel_mask != 0) { channel_mask = 255 - (255 - channel_mask) * (1 - (255 - out[3]) / 255); } diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 3bb444c80..7663f96a9 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -24,7 +24,7 @@ * * This cast is safe, as the top 32-bits of HFILE are guaranteed to be zero, * see - * https://docs.microsoft.com/en-us/windows/win32/winprog64/interprocess-communication + * https://learn.microsoft.com/en-us/windows/win32/winprog64/interprocess-communication */ #ifndef USE_WIN32_FILEIO #define fd_to_tiff_fd(fd) (fd) @@ -916,7 +916,7 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt dump_state(clientstate); if (state->state == 0) { - TRACE(("Encoding line bt line")); + TRACE(("Encoding line by line")); while (state->y < state->ysize) { state->shuffle( state->buffer, diff --git a/src/thirdparty/raqm/README.md b/src/thirdparty/raqm/README.md index 02e996e7a..3354a4d25 100644 --- a/src/thirdparty/raqm/README.md +++ b/src/thirdparty/raqm/README.md @@ -81,5 +81,5 @@ The following projects have patches to support complex text layout using Raqm: [1]: https://github.com/fribidi/fribidi [2]: https://github.com/Tehreer/SheenBidi [3]: https://github.com/harfbuzz/harfbuzz -[4]: https://www.freetype.org +[4]: https://freetype.org/ [5]: https://www.gtk.org/gtk-doc diff --git a/winbuild/README.md b/winbuild/README.md index 611d1ed1a..d8538fbf3 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -11,8 +11,8 @@ 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.12 or newer (available as Visual Studio component). -* Tested on Windows Server 2016 with Visual Studio 2017 Community (AppVeyor). -* Tested on Windows Server 2019 with Visual Studio 2019 Enterprise (GitHub Actions). +* Tested on Windows Server 2016 with Visual Studio 2017 Community, and Windows Server 2019 with Visual Studio 2022 Community (AppVeyor). +* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise (GitHub Actions). The following is a simplified version of the script used on AppVeyor: ``` diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 567ca4f7e..b4b15cc1e 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -1,5 +1,6 @@ import os import platform +import re import shutil import struct import subprocess @@ -111,6 +112,11 @@ deps = { + "/libjpeg-turbo/files/2.1.4/libjpeg-turbo-2.1.4.tar.gz/download", "filename": "libjpeg-turbo-2.1.4.tar.gz", "dir": "libjpeg-turbo-2.1.4", + "license": ["README.ijg", "LICENSE.md"], + "license_pattern": ( + "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" + ".+(libjpeg-turbo Licenses\n======================\n\n.+)$" + ), "build": [ cmd_cmake( [ @@ -132,9 +138,11 @@ deps = { "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/zlib1212.zip", - "filename": "zlib1212.zip", - "dir": "zlib-1.2.12", + "url": "https://zlib.net/zlib1213.zip", + "filename": "zlib1213.zip", + "dir": "zlib-1.2.13", + "license": "README", + "license_pattern": "Copyright notice:\n\n(.+)$", "build": [ cmd_nmake(r"win32\Makefile.msc", "clean"), cmd_nmake(r"win32\Makefile.msc", "zlib.lib"), @@ -143,10 +151,73 @@ deps = { "headers": [r"z*.h"], "libs": [r"*.lib"], }, + "xz": { + "url": SF_PROJECTS + "/lzmautils/files/xz-5.2.7.tar.gz/download", + "filename": "xz-5.2.7.tar.gz", + "dir": "xz-5.2.7", + "license": "COPYING", + "patch": { + r"src\liblzma\api\lzma.h": { + "#ifndef LZMA_API_IMPORT": "#ifndef LZMA_API_IMPORT\n#define LZMA_API_STATIC", # noqa: E501 + }, + r"windows\vs2019\liblzma.vcxproj": { + # retarget to default toolset (selected by vcvarsall.bat) + "v142": "$(DefaultPlatformToolset)", # noqa: E501 + # retarget to latest (selected by vcvarsall.bat) + "10.0": "$(WindowsSDKVersion)", # noqa: E501 + }, + }, + "build": [ + cmd_msbuild(r"windows\vs2019\liblzma.vcxproj", "Release", "Clean"), + cmd_msbuild(r"windows\vs2019\liblzma.vcxproj", "Release", "Build"), + cmd_mkdir(r"{inc_dir}\lzma"), + cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"), + ], + "headers": [r"src\liblzma\api\lzma.h"], + "libs": [r"windows\vs2019\Release\{msbuild_arch}\liblzma\liblzma.lib"], + }, + "libwebp": { + "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.2.4.tar.gz", + "filename": "libwebp-1.2.4.tar.gz", + "dir": "libwebp-1.2.4", + "license": "COPYING", + "build": [ + cmd_rmdir(r"output\release-static"), # clean + cmd_nmake( + "Makefile.vc", + "all", + [ + "CFG=release-static", + "RTLIBCFG=dynamic", + "OBJDIR=output", + "ARCH={architecture}", + "LIBWEBP_BASENAME=webp", + ], + ), + cmd_mkdir(r"{inc_dir}\webp"), + cmd_copy(r"src\webp\*.h", r"{inc_dir}\webp"), + ], + "libs": [r"output\release-static\{architecture}\lib\*.lib"], + }, "libtiff": { "url": "https://download.osgeo.org/libtiff/tiff-4.4.0.tar.gz", "filename": "tiff-4.4.0.tar.gz", "dir": "tiff-4.4.0", + "license": "COPYRIGHT", + "patch": { + r"cmake\LZMACodec.cmake": { + # fix typo + "${{LZMA_FOUND}}": "${{LIBLZMA_FOUND}}", + }, + r"libtiff\tif_lzma.c": { + # link against liblzma.lib + "#ifdef LZMA_SUPPORT": '#ifdef LZMA_SUPPORT\n#pragma comment(lib, "liblzma.lib")', # noqa: E501 + }, + r"libtiff\tif_webp.c": { + # link against webp.lib + "#ifdef WEBP_SUPPORT": '#ifdef WEBP_SUPPORT\n#pragma comment(lib, "webp.lib")', # noqa: E501 + }, + }, "build": [ cmd_cmake("-DBUILD_SHARED_LIBS:BOOL=OFF"), cmd_nmake(target="clean"), @@ -156,26 +227,11 @@ deps = { "libs": [r"libtiff\*.lib"], # "bins": [r"libtiff\*.dll"], }, - "libwebp": { - "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.2.4.tar.gz", - "filename": "libwebp-1.2.4.tar.gz", - "dir": "libwebp-1.2.4", - "build": [ - cmd_rmdir(r"output\release-static"), # clean - cmd_nmake( - "Makefile.vc", - "all", - ["CFG=release-static", "OBJDIR=output", "ARCH={architecture}"], - ), - cmd_mkdir(r"{inc_dir}\webp"), - cmd_copy(r"src\webp\*.h", r"{inc_dir}\webp"), - ], - "libs": [r"output\release-static\{architecture}\lib\*.lib"], - }, "libpng": { - "url": SF_PROJECTS + "/libpng/files/libpng16/1.6.37/lpng1637.zip/download", - "filename": "lpng1637.zip", - "dir": "lpng1637", + "url": SF_PROJECTS + "/libpng/files/libpng16/1.6.38/lpng1638.zip/download", + "filename": "lpng1638.zip", + "dir": "lpng1638", + "license": "LICENSE", "build": [ # lint: do not inline cmd_cmake(("-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF")), @@ -186,10 +242,25 @@ deps = { "headers": [r"png*.h"], "libs": [r"libpng16.lib"], }, + "brotli": { + "url": "https://github.com/google/brotli/archive/refs/tags/v1.0.9.tar.gz", + "filename": "brotli-1.0.9.tar.gz", + "dir": "brotli-1.0.9", + "license": "LICENSE", + "build": [ + cmd_cmake(), + cmd_nmake(target="clean"), + cmd_nmake(target="brotlicommon-static"), + cmd_nmake(target="brotlidec-static"), + cmd_xcopy(r"c\include", "{inc_dir}"), + ], + "libs": ["*.lib"], + }, "freetype": { "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.12.1.tar.gz", # noqa: E501 "filename": "freetype-2.12.1.tar.gz", "dir": "freetype-2.12.1", + "license": ["LICENSE.TXT", r"docs\FTL.TXT", r"docs\GPLv2.TXT"], "patch": { r"builds\windows\vc2010\freetype.vcxproj": { # freetype setting is /MD for .dll and /MT for .lib, we need /MD @@ -198,13 +269,13 @@ deps = { '': '\n $(WindowsSDKVersion)', # noqa: E501 }, r"builds\windows\vc2010\freetype.user.props": { - "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ", # noqa: E501 + "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ;FT_CONFIG_OPTION_USE_BROTLI", # noqa: E501 "": r"{dir_harfbuzz}\src;{inc_dir}", # noqa: E501 "": "{lib_dir}", # noqa: E501 - "": "zlib.lib;libpng16.lib", # noqa: E501 + "": "zlib.lib;libpng16.lib;brotlicommon-static.lib;brotlidec-static.lib", # noqa: E501 }, r"src/autofit/afshaper.c": { - # link against harfbuzz.lib once it becomes available + # link against harfbuzz.lib "#ifdef FT_CONFIG_OPTION_USE_HARFBUZZ": '#ifdef FT_CONFIG_OPTION_USE_HARFBUZZ\n#pragma comment(lib, "harfbuzz.lib")', # noqa: E501 }, }, @@ -225,6 +296,7 @@ deps = { "url": SF_PROJECTS + "/lcms/files/lcms/2.13/lcms2-2.13.1.tar.gz/download", "filename": "lcms2-2.13.1.tar.gz", "dir": "lcms2-2.13.1", + "license": "COPYING", "patch": { r"Projects\VC2022\lcms2_static\lcms2_static.vcxproj": { # default is /MD for x86 and /MT for x64, we need /MD always @@ -250,8 +322,9 @@ deps = { "url": "https://github.com/uclouvain/openjpeg/archive/v2.5.0.tar.gz", "filename": "openjpeg-2.5.0.tar.gz", "dir": "openjpeg-2.5.0", + "license": "LICENSE", "build": [ - cmd_cmake(("-DBUILD_THIRDPARTY:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF")), + cmd_cmake(("-DBUILD_CODEC:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF")), cmd_nmake(target="clean"), cmd_nmake(target="openjp2"), cmd_mkdir(r"{inc_dir}\openjpeg-2.5.0"), @@ -264,6 +337,7 @@ deps = { "url": "https://github.com/ImageOptim/libimagequant/archive/e4c1334be0eff290af5e2b4155057c2953a313ab.zip", # noqa: E501 "filename": "libimagequant-e4c1334be0eff290af5e2b4155057c2953a313ab.zip", "dir": "libimagequant-e4c1334be0eff290af5e2b4155057c2953a313ab", + "license": "COPYRIGHT", "patch": { "CMakeLists.txt": { "if(OPENMP_FOUND)": "if(false)", @@ -281,9 +355,10 @@ deps = { "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/5.1.0.zip", - "filename": "harfbuzz-5.1.0.zip", - "dir": "harfbuzz-5.1.0", + "url": "https://github.com/harfbuzz/harfbuzz/archive/5.3.1.zip", + "filename": "harfbuzz-5.3.1.zip", + "dir": "harfbuzz-5.3.1", + "license": "COPYING", "build": [ cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"), cmd_nmake(target="clean"), @@ -296,6 +371,7 @@ deps = { "url": "https://github.com/fribidi/fribidi/archive/v1.0.12.zip", "filename": "fribidi-1.0.12.zip", "dir": "fribidi-1.0.12", + "license": "COPYING", "build": [ cmd_copy(r"COPYING", r"{bin_dir}\fribidi-1.0.12-COPYING"), cmd_copy(r"{winbuild_dir}\fribidi.cmake", r"CMakeLists.txt"), @@ -407,8 +483,8 @@ def write_script(name, lines): name = os.path.join(build_dir, name) lines = [line.format(**prefs) for line in lines] print("Writing " + name) - with open(name, "w") as f: - f.write("\n\r".join(lines)) + with open(name, "w", newline="") as f: + f.write(os.linesep.join(lines)) if verbose: for line in lines: print(" " + line) @@ -432,6 +508,21 @@ def build_dep(name): extract_dep(dep["url"], dep["filename"]) + licenses = dep["license"] + if isinstance(licenses, str): + licenses = [licenses] + license_text = "" + for license_file in licenses: + with open(os.path.join(sources_dir, dir, license_file)) as f: + license_text += f.read() + if "license_pattern" in dep: + match = re.search(dep["license_pattern"], license_text, re.DOTALL) + license_text = "\n".join(match.groups()) + assert len(license_text) > 50 + with open(os.path.join(license_dir, f"{dir}.txt"), "w") as f: + print(f"Writing license {dir}.txt") + f.write(license_text) + for patch_file, patch_list in dep.get("patch", {}).items(): patch_file = os.path.join(sources_dir, dir, patch_file.format(**prefs)) with open(patch_file) as f: @@ -478,6 +569,7 @@ def build_pillow(): cmd_cd("{pillow_dir}"), *prefs["header"], cmd_set("DISTUTILS_USE_SDK", "1"), # use same compiler to build Pillow + cmd_set("py_vcruntime_redist", "true"), # always use /MD, never /MT r'"{python_dir}\{python_exe}" setup.py build_ext --vendor-raqm --vendor-fribidi %*', # noqa: E501 ] @@ -552,10 +644,12 @@ if __name__ == "__main__": bin_dir = os.path.join(build_dir, "bin") # directory for storing project files sources_dir = build_dir + sources_dir + # copy dependency licenses to this directory + license_dir = os.path.join(build_dir, "license") shutil.rmtree(build_dir, ignore_errors=True) os.makedirs(build_dir, exist_ok=False) - for path in [inc_dir, lib_dir, bin_dir, sources_dir]: + for path in [inc_dir, lib_dir, bin_dir, sources_dir, license_dir]: os.makedirs(path, exist_ok=True) prefs = { @@ -573,6 +667,7 @@ if __name__ == "__main__": "lib_dir": lib_dir, "bin_dir": bin_dir, "src_dir": sources_dir, + "license_dir": license_dir, # Compilers / Tools **msvs, "cmake": "cmake.exe", # TODO find CMAKE automatically