diff --git a/.appveyor.yml b/.appveyor.yml index 1cca224ab3f..20908052bab 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/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000000..d1d82433553 --- /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 fa1e8a50309..db030704607 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -14,6 +14,10 @@ on: 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 527f26d35d4..6195f973b05 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/release-drafter.yml b/.github/workflows/release-drafter.yml index 7ee76c4ac8c..9e2fdc09604 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 cc5e0d488ab..ffac91ceca7 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 794159cec67..5b9ab0edab3 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.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 @@ -44,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 a789726079e..c68d43935e2 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 7ddb71e1f68..ccf6e193a6d 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 dda1b357785..219189cf208 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 b9accfdf9a5..36bd03e7ee9 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.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 @@ -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: @@ -171,7 +175,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5614ad5f228..4c8a1b85f74 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: @@ -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 c73f254313d..69f9e547602 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 eeb4b391ee3..f81bcb956fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 1d4103a7c60..f189cf9dd37 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,48 @@ Changelog (Pillow) 9.3.0 (unreleased) ------------------ +- 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 BC6 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] diff --git a/MANIFEST.in b/MANIFEST.in index 26f9401f2d9..08f6dfc0877 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 219dda1de50..8f2862948a8 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 5e9adaf7e95..e7c0ebc5aa5 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/images/bc6h.dds b/Tests/images/bc6h.dds new file mode 100644 index 00000000000..77993a0c1ea 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 00000000000..609f114890c 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 00000000000..2ab1b195b19 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 00000000000..6a3b73d5fc9 Binary files /dev/null and b/Tests/images/bc6h_sf.png differ diff --git a/Tests/images/bw_gradient.imt b/Tests/images/bw_gradient.imt new file mode 100644 index 00000000000..d765cf95fac 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 00000000000..700185d88ae 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 00000000000..f5e3d129d5d Binary files /dev/null and b/Tests/images/child_ifd_jpeg.tiff differ diff --git a/Tests/images/hopper_palette_chunk_second.fli b/Tests/images/hopper_palette_chunk_second.fli new file mode 100644 index 00000000000..54447de0af7 Binary files /dev/null and b/Tests/images/hopper_palette_chunk_second.fli differ diff --git a/Tests/images/text_float_coord.png b/Tests/images/text_float_coord.png new file mode 100644 index 00000000000..49468698cd4 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 00000000000..50bdac3d8f3 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 5ecb42006c4..70860f2fcc4 100644 Binary files a/Tests/images/unimplemented_dxgi_format.dds and b/Tests/images/unimplemented_dxgi_format.dds differ diff --git a/Tests/test_000_sanity.py b/Tests/test_000_sanity.py index 59fbac527ed..3fd982474d4 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 284f72205f3..c4e9cd36811 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 0ff05f608c2..cdaad5940af 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) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 604d54d889c..f6860a9a43d 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -58,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: diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 35100119926..4b9f8949ef5 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -16,6 +16,8 @@ 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 766c5064920..015dda992c6 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: @@ -203,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(): @@ -266,20 +256,19 @@ def _test_readline_file_psfile(test_string, ending): _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 a7d43d2e922..b8b999d70d0 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -4,7 +4,7 @@ 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 bf7cc2ea865..926f5c1eea8 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -99,17 +99,24 @@ def test_palette_not_needed_for_second_frame(): 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 @@ -120,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: @@ -808,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"), - ] - - 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 +@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"), + ] - # Assert that the new duration is the total of the identical frames - assert reread.info["duration"] == 8500 + 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 def test_number_of_loops(tmp_path): @@ -1102,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 e458a197ca4..5cf93713be4 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 00000000000..f56acc42949 --- /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 12edd75828e..adbb72aa576 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -150,27 +150,30 @@ def test_icc(self, tmp_path): 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 @@ def test_bad_mpo_header(self): # 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 7942d6b9afd..cd142e67fc7 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 86a0fda04e5..d9066c58912 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -509,20 +509,13 @@ def test_palette_save(self, im, tmp_path): # 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") diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index e1c1c361b1e..be7c8d0c86a 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 ba6663cd3ba..485adf7853e 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 b5273353ce3..4129e878317 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -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 @@ -47,38 +52,6 @@ def test_monochrome(tmp_path): assert os.path.getsize(outfile) < (5000 if features.check("libtiff") else 15000) -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) - - def test_unsupported_mode(tmp_path): im = hopper("LA") outfile = str(tmp_path / "temp_LA.pdf") diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index cbbb7df1d12..7d8b5139aa9 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 8706cb950dd..1a5ba594f3f 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -84,6 +84,24 @@ def test_context_manager(self): 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 @@ def test_unknown_pixel_mode(self): 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 @@ def test__delitem__(self): 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 @@ def test_planar_configuration_save(self, tmp_path): 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") - - im = hopper(mode) - im.save(outfile) + @pytest.mark.parametrize("mode", ("P", "PA")) + def test_palette(self, mode, tmp_path): + outfile = str(tmp_path / "temp.tif") - with Image.open(outfile) as reloaded: - assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) + im = hopper(mode) + im.save(outfile) - 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_webp_metadata.py b/Tests/test_file_webp_metadata.py index e6d6fc63fc4..f77a245c035 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 4477ee29d55..664663fd6bb 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 delete_tempfile(): 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 7cebed127d9..ab945e946f0 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -620,6 +620,7 @@ def test_remap_palette_transparency(self): 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 diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index bb09a77088a..a000cb64c70 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -345,13 +345,14 @@ def test_reference_counting(self): @pytest.mark.parametrize("mode", ("P", "PA")) def test_p_putpixel_rgb_rgba(self, mode): - for color in [(255, 0, 0), (255, 0, 0, 127)]: + 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) - alpha = color[3] if len(color) == 4 and mode == "PA" else 255 - assert im.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, alpha) + if len(color) == 3: + color += (255,) + assert im.convert("RGBA").getpixel((0, 0)) == color class TestImagePutPixelError(AccessTest): diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 7e5fd6fe13c..ae3518e44cb 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -35,10 +35,13 @@ def test_with_dtype(dtype): 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_reduce.py b/Tests/test_image_reduce.py index 801161511b9..ae8d740a027 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -196,11 +196,11 @@ 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) @pytest.mark.parametrize("factor", remarkable_factors) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 5ce98a23568..53ceb6df030 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -554,44 +554,48 @@ def test_no_passthrough(self): # 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}", - ) - - def test_skip_vertical(self): + 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}", + ) + + @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 fbed276b8b7..5cb7c9a8be8 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 split(mode): 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_imagedraw.py b/Tests/test_imagedraw.py index d1dd1e47c1c..76b7c65cc37 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 e4e8a38cb59..6fc829f1a54 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 8bc94401e80..221ef8cdb26 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 16da87d469a..e5cf8b6310b 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -28,497 +28,527 @@ 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")) + + +@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 + + +@pytest.fixture(scope="module") +def font(layout_engine): + return ImageFont.truetype(FONT_PATH, FONT_SIZE, layout_engine=layout_engine) - 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")) +def test_font_properties(font): + assert font.path == FONT_PATH + assert font.size == FONT_SIZE - def test_font_properties(self): - ttf = self.get_font() - assert ttf.path == FONT_PATH - assert ttf.size == FONT_SIZE + font_copy = font.font_variant() + assert font_copy.path == FONT_PATH + assert font_copy.size == FONT_SIZE - ttf_copy = ttf.font_variant() - assert ttf_copy.path == FONT_PATH - assert ttf_copy.size == FONT_SIZE + font_copy = font.font_variant(size=FONT_SIZE + 1) + assert font_copy.size == FONT_SIZE + 1 - ttf_copy = ttf.font_variant(size=FONT_SIZE + 1) - assert ttf_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 - 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_with_name(self): - self.get_font() - self._render(FONT_PATH) +def _render(font, layout_engine): + txt = "Hello World!" + ttf = ImageFont.truetype(font, FONT_SIZE, layout_engine=layout_engine) + ttf.getbbox(txt) - def _font_as_bytes(self): + 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") - 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") +@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) - # 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, f"Tests/images/multiline_text{ext}.png", 0.01) - 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 - ) +def test_unknown_align(font): + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) - # Test that multiline_textsize corresponds to ImageFont.textsize() - # for single line text - assert ttf.getsize("A") == draw.multiline_textsize("A", font=ttf) + # Act/Assert + with pytest.raises(ValueError): + draw.multiline_text((0, 0), TEST_TEXT, font=font, align="unknown") - # 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) +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) - # 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 + 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 ) - # 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 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_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 textbbox() can pass on additional arguments - # to multiline_textbbox() - draw.textbbox((0, 0), TEST_TEXT, font=ttf, spacing=4) + # Test that multiline_textbbox corresponds to ImageFont.textbbox() + # for single line text + assert font.getbbox("A") == draw.multiline_textbbox((0, 0), "A", font=font) - def test_multiline_width(self): - ttf = self.get_font() - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) + # 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 - - 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) - - 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) - - # 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) - - # 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 (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] - - # 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] - - # text length is undefined for vertical text - pytest.raises(ValueError, draw.textlength, word) - - def test_unrotated_transposed_font(self): - img_grey = Image.new("L", (100, 100)) - draw = ImageDraw.Draw(img_grey) - word = "testing" - font = self.get_font() - - 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) - - # 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) - - # Check boxes a and b are same size - assert box_size_a == box_size_b - - # 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] - - assert length_a == length_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) - - # Act - mask = transposed_font.getmask(text) - - # 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) - - # Act - mask = transposed_font.getmask(text) - - # Assert - assert mask.size == (108, 13) - - def test_free_type_font_get_name(self): - # Arrange - font = self.get_font() - - # Act - name = font.getname() - - # Assert - assert ("FreeMono", "Regular") == name - - def test_free_type_font_get_metrics(self): - # Arrange - font = self.get_font() - - # 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_offset(self): - # Arrange - font = self.get_font() - text = "offset this" - - # Act - with pytest.warns(DeprecationWarning) as log: - offset = font.getoffset(text) - - # Assert - assert len(log) == 1 - assert offset == (0, 3) - - def test_free_type_font_get_mask(self): - # Arrange - font = self.get_font() - text = "mask this" - - # Act - mask = font.getmask(text) - - # Assert - assert mask.size == (108, 13) - - def test_load_path_not_found(self): - # Arrange - filename = "somefilenamethatdoesntexist.ttf" - - # Act/Assert - with pytest.raises(OSError): - ImageFont.load_path(filename) + assert len(log) == 2 + + +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) + + +@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" + + 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 box_size_a == font.getsize(word) + assert len(log) == 2 + bbox_a = draw.textbbox((10, 10), word) + + # 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 (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] + + # 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] + + # text length is undefined for vertical text + pytest.raises(ValueError, 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" + + 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) + + # 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) + + # Check boxes a and b are same size + assert box_size_a == box_size_b + + # 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] + + assert length_a == length_b + + +@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) + + # Assert + assert mask.size == (13, 108) + + +@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) + + # Act + mask = transposed_font.getmask(text) + + # Assert + assert mask.size == (108, 13) + + +def test_free_type_font_get_name(font): + assert ("FreeMono", "Regular") == font.getname() + + +def test_free_type_font_get_metrics(font): + ascent, descent = font.getmetrics() + + assert isinstance(ascent, int) + assert isinstance(descent, int) + assert (ascent, descent) == (16, 4) + + +def test_free_type_font_get_offset(font): + # Arrange + text = "offset this" + + # Act + with pytest.warns(DeprecationWarning) as log: + offset = font.getoffset(text) + + # Assert + assert len(log) == 1 + assert offset == (0, 3) + + +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.truetype(filename) - - 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) - - # 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") - - 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_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" - - 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) + ImageFont.truetype(f) - # fails with 14.7 - assert_image_similar_tofile(img, target, 6.2) - def _test_fake_loading_font(self, monkeypatch, path_to_fake, fontname): +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) + + # Assert + assert_image_equal_tofile(im, "Tests/images/default_font.png") + + +def test_getbbox_empty(font): + # issue #2614, should not crash. + assert (0, 0, 0, 0) == font.getbbox("") + + +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) + + +def test_unicode_pilfont(): + # should not segfault, should return UnicodeDecodeError + # issue #2826 + font = ImageFont.load_default() + with pytest.raises(UnicodeEncodeError): + font.getbbox("’") + + +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,506 @@ def loadable_font(filepath, size, index, encoding, *args, **kwargs): 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" + 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 + _test_fake_loading_font(font_directory + "/Arial.ttf", "Arial.ttf") + _test_fake_loading_font(font_directory + "/Arial.ttf", "Arial") + + # Test that non-ttf fonts can be found without the extension + _test_fake_loading_font(font_directory + "/Single.otf", "Single") + + # 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, ) - self._test_fake_loading_font( - monkeypatch, font_directory + "/Arial.ttf", "Arial" + assert font.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width) == ( + 48 + stroke_width * 2, + 36 + stroke_width * 4, ) + assert len(log) == 2 - # 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" - ) +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") - @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, - ) - assert t.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(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") - - def test_variation_get(self): - font = self.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(NotImplementedError): - font.get_variation_axes() - return - - with pytest.raises(OSError): +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() - - 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}, - ] - - 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} - ] - - 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") - - try: + return + + with pytest.raises(OSError): + font.get_variation_names() + with pytest.raises(OSError): + font.get_variation_axes() + + 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}, + ] + + 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} + ] + + +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 + else: + raise - with pytest.raises(OSError): + +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 - 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) + with pytest.raises(OSError): + font.set_variation_by_name("Bold") - 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) + 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) + _check_text(font, "Tests/images/variation_adobe_name.png", 11) - def test_variation_set_by_axes(self): - font = self.get_font() + 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) + _check_text(font, "Tests/images/variation_tiny_name.png", 40) - 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]) +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 - font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) + with pytest.raises(OSError): 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) + 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) - 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"), + 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(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) + 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) - bbox_expected = (left, top, left + width, top + height) + assert d.textbbox((0, 0), text, f, anchor=anchor) == bbox_expected - f = ImageFont.truetype( - "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=self.LAYOUT_ENGINE - ) + assert_image_similar_tofile(im, path, 7) - 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"), - ), + +@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 ) - 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 - ) + # 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) - # 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) - assert_image_similar_tofile(im, target, 4) - def test_anchor_invalid(self): - font = self.get_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), - ) +def test_anchor_invalid(font): + im = Image.new("RGB", (100, 100), "white") + d = ImageDraw.Draw(im) + d.font = font - @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, + 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), ) - im = Image.new("RGB", (160, 35), "white") - draw = ImageDraw.Draw(im) - draw.text((2, 2), text, "black", font) - assert_image_equal_tofile(im, target) +@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_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, - ) + im = Image.new("RGB", (160, 35), "white") + draw = ImageDraw.Draw(im) + draw.text((2, 2), text, "black", font) - 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_equal_tofile(im, target) - assert_image_similar_tofile(im, target, 0.03) - def test_standard_embedded_color(self): - txt = "Hello World!" - ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=self.LAYOUT_ENGINE) - ttf.getbbox(txt) +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, + ) - 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", (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, "Tests/images/standard_embedded.png", 6.2) + assert_image_similar_tofile(im, target, 0.03) - def test_cbdt(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) +def test_standard_embedded_color(layout_engine): + txt = "Hello World!" + ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) + ttf.getbbox(txt) - d.text((10, 10), "\U0001f469", font=font, 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/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") + assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1) - 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) +@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) - 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 - ) - 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_sbix(self): - try: - font = ImageFont.truetype( - "Tests/fonts/chromacheck-sbix.woff", - size=300, - layout_engine=self.LAYOUT_ENGINE, + im, "Tests/images/text_float_coord_1_alt.png", 1 ) + else: + raise - im = Image.new("RGB", (400, 400), "white") - d = ImageDraw.Draw(im) - d.text((50, 50), "\uE901", font=font, embedded_color=True) +def test_cbdt(layout_engine): + try: + font = ImageFont.truetype( + "Tests/fonts/NotoColorEmoji.ttf", size=109, layout_engine=layout_engine + ) - 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") + im = Image.new("RGB", (150, 150), "white") + d = ImageDraw.Draw(im) - def test_sbix_mask(self): - try: - font = ImageFont.truetype( - "Tests/fonts/chromacheck-sbix.woff", - size=300, - layout_engine=self.LAYOUT_ENGINE, - ) + 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") - im = Image.new("RGB", (400, 400), "white") - d = ImageDraw.Draw(im) - d.text((50, 50), "\uE901", (100, 0, 0), font=font) +def test_cbdt_mask(layout_engine): + try: + font = ImageFont.truetype( + "Tests/fonts/NotoColorEmoji.ttf", size=109, layout_engine=layout_engine + ) + + im = Image.new("RGB", (150, 150), "white") + d = ImageDraw.Draw(im) - 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") + d.text((10, 10), "\U0001f469", "black", font=font) + + 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_version("freetype2", "2.10.0") - def test_colr(self): + +def test_sbix(layout_engine): + try: font = ImageFont.truetype( - "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", - size=64, - layout_engine=self.LAYOUT_ENGINE, + "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine ) - im = Image.new("RGB", (300, 75), "white") + im = Image.new("RGB", (400, 400), "white") d = ImageDraw.Draw(im) - d.text((15, 5), "Bungee", font=font, embedded_color=True) + 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") - assert_image_similar_tofile(im, "Tests/images/colr_bungee.png", 21) - @skip_unless_feature_version("freetype2", "2.10.0") - def test_colr_mask(self): +def test_sbix_mask(layout_engine): + try: font = ImageFont.truetype( - "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", - size=64, - layout_engine=self.LAYOUT_ENGINE, + "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine ) - im = Image.new("RGB", (300, 75), "white") + im = Image.new("RGB", (400, 400), "white") d = ImageDraw.Draw(im) - d.text((15, 5), "Bungee", "black", font=font) + 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) - assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 22) + d.text((15, 5), "Bungee", "black", font=font) - 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/colr_bungee_mask.png", 22) -@skip_unless_feature("raqm") -class TestImageFont_RaqmLayout(TestImageFont): - LAYOUT_ENGINE = ImageFont.Layout.RAQM +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_imagemorph.py b/Tests/test_imagemorph.py index 6de95306836..29c71f917c4 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 01e40e6d4d5..c9b2fd865b8 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 475d249ed09..5bda2811717 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 55d7c94798f..3e147a9efec 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -45,10 +45,10 @@ def show_image(self, image, **options): 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 a929910b3cc..995d0ee1f38 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 - im_tk = ImageTk.PhotoImage(im) + # this should not crash + im_tk = ImageTk.PhotoImage(im) - assert im_tk.width() == im.width - assert im_tk.height() == im.height + 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) 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 6e8a2ac589f..efcdab9ec43 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) - - w, h = im_in.size + im_in = original.convert(mode) + verify(im_in) - im_out = im_in.copy() - verify(im_out) # copy + w, h = im_in.size - im_out = im_in.transform((w, h), Image.Transform.EXTENT, (0, 0, w, h)) - verify(im_out) # transform + im_out = im_in.copy() + verify(im_out) # copy - filename = str(tmp_path / "temp.im") - im_in.save(filename) + im_out = im_in.transform((w, h), Image.Transform.EXTENT, (0, 0, w, h)) + verify(im_out) # transform - 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) + filename = str(tmp_path / "temp.im") + im_in.save(filename) - 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 9735837bcba..185e477ecc5 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 _to_array(mode, dtype): ("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 9be92770ab9..dec652df88f 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 66eeaf6f8e5..a9b33e437e3 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 ff54853a337..dc629666c30 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: @@ -46,6 +49,9 @@ or ``RGB`` data. 16-colour images are read as ``P`` images. 4-bit run-length enc is not supported. Support for reading 8-bit run-length encoding was added in Pillow 9.1.0. +Opening +~~~~~~~ + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -78,6 +84,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 +143,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 +185,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 +294,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 +313,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 +363,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 +414,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 +499,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 +615,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 +658,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 +853,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 +874,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 +894,11 @@ 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** @@ -871,6 +933,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: @@ -922,8 +989,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: @@ -1035,6 +1104,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** @@ -1058,7 +1132,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:: @@ -1173,6 +1247,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: @@ -1188,6 +1267,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: @@ -1227,6 +1311,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 @@ -1326,6 +1415,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: @@ -1350,6 +1444,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 c731e274600..45af4c5714c 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 4567d4d3e79..794fa238f6f 100644 --- a/docs/reference/ExifTags.rst +++ b/docs/reference/ExifTags.rst @@ -10,7 +10,7 @@ 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 + The TAGS dictionary maps 16-bit integer EXIF tag enumerations to descriptive string names. For instance: >>> from PIL.ExifTags import TAGS diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 1ef9079fba0..25f98b767b3 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -285,8 +285,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 +298,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 +443,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 +491,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 +547,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 +616,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 +666,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 +710,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 +742,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/releasenotes/5.2.0.rst b/docs/releasenotes/5.2.0.rst index 75e8da6554d..d9b8f0fb7c8 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.2.0.rst b/docs/releasenotes/9.2.0.rst index 9c102f1776a..6dbfa2702eb 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/versioning.rst b/docs/releasenotes/versioning.rst index 87f2ba422b3..2a0af9e59ec 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/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 7bb73fc9388..1041ab763d2 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -375,6 +375,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 +415,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 bba48016140..eea6e31534c 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -101,6 +101,8 @@ 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 @@ def _open(self): 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/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index e13b1779ccc..908bed9f427 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 @@ def _open(self): 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 596b2e76f97..dd1b21f2e63 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -301,11 +301,13 @@ def _seek(self, frame, update_image=True): self.im.paste(self.dispose, self.dispose_extent) 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" @@ -315,7 +317,6 @@ def _seek(self, frame, update_image=True): 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 @@ -388,7 +389,8 @@ def _rgb(color): 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 = [ @@ -412,9 +414,9 @@ def load_prepare(self): 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 @@ -431,8 +433,12 @@ def load_prepare(self): 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 not self._prev_im: return @@ -518,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 78ccfb9cfa7..31b0ff46901 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 d2819e07662..d5080a05c2f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -679,12 +679,24 @@ def __array_interface__(self): 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): @@ -1949,11 +1961,7 @@ def remap_palette(self, dest_map, source_palette=None): 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: diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e84dafb12e9..3a6063a6c7c 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -482,8 +482,8 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): # 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/ImageFont.py b/src/PIL/ImageFont.py index 9386d008602..b3a7cdb9880 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -141,6 +141,8 @@ def getsize(self, text, *args, **kwargs): 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 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) 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 @@ def getbbox( 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 @@ def getsize( 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 @@ def getsize( 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 @@ def getsize_multiline( 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 @@ def getsize_multiline( 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 @@ def getoffset(self, text): 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 @@ def getmask( 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 @@ def getmask2( 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 @@ -852,6 +860,8 @@ def getsize(self, text, *args, **kwargs): .. 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 0c3f900caac..443c540b61a 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -21,7 +21,7 @@ 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 853147ac28f..fe76c86f40e 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -50,15 +50,24 @@ def palette(self): @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 @@ def getcolor(self, color, image=None): 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 9f9a551fb6f..76f42a3072d 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 @@ def show_file(self, path=None, **options): 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 7c90a0ad893..949cf1fbf9d 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -107,6 +107,7 @@ def __init__(self, image=None, size=None, **kw): 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 5790acdaf52..dc707801274 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -39,15 +39,19 @@ def _open(self): # 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 @@ def _open(self): # 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 @@ def _open(self): 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 @@ def _open(self): 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/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 59b89e9885f..cd454b755c0 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 f2f1299122e..04a63bd2b44 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -173,6 +173,7 @@ (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"), @@ -1148,6 +1149,39 @@ def tell(self): """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. diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index e3094b4db47..3f3a1ccd2a7 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -160,6 +160,7 @@ def lookup(tag, group=None): 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), diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index c1f4b730fbf..5eaeb10ccd5 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/decode.c b/src/decode.c index cb018a4e706..7a9b956c559 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 22b36eb7acc..a57b74b61ac 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/Paste.c b/src/libImaging/Paste.c index fafd8141edf..acf5202e50c 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 04a835dcdde..7663f96a966 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) diff --git a/src/thirdparty/raqm/README.md b/src/thirdparty/raqm/README.md index 02e996e7a9c..3354a4d2550 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/build_prepare.py b/winbuild/build_prepare.py index 94e5dd87114..ad7b1ddb646 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -281,9 +281,9 @@ def cmd_msbuild( "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.0.zip", + "filename": "harfbuzz-5.3.0.zip", + "dir": "harfbuzz-5.3.0", "build": [ cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"), cmd_nmake(target="clean"),