diff --git a/.appveyor.yml b/.appveyor.yml
index 0f5dea9c515..4c5a7f9ee47 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -14,7 +14,7 @@ environment:
ARCHITECTURE: x86
APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022
- PYTHON: C:/Python38-x64
- ARCHITECTURE: x64
+ ARCHITECTURE: AMD64
APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017
diff --git a/.coveragerc b/.coveragerc
index f71b6b1a281..46df3f90d27 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,15 +2,14 @@
[report]
# Regexes for lines to exclude from consideration
-exclude_lines =
- # Have to re-enable the standard pragma:
- pragma: no cover
-
- # Don't complain if non-runnable code isn't run:
+exclude_also =
+ # Don't complain if non-runnable code isn't run
if 0:
if __name__ == .__main__.:
# Don't complain about debug code
if DEBUG:
+ # Don't complain about compatibility code for missing optional dependencies
+ except ImportError
[run]
omit =
diff --git a/.github/problem-matchers/gcc.json b/.github/problem-matchers/gcc.json
new file mode 100644
index 00000000000..8e2866afe23
--- /dev/null
+++ b/.github/problem-matchers/gcc.json
@@ -0,0 +1,18 @@
+{
+ "__comment": "Based on vscode-cpptools' Extension/package.json gcc rule",
+ "problemMatcher": [
+ {
+ "owner": "gcc-problem-matcher",
+ "pattern": [
+ {
+ "regexp": "^\\s*(.*):(\\d+):(\\d+):\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "severity": 4,
+ "message": 5
+ }
+ ]
+ }
+ ]
+}
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index 4d855469a12..3711d91f0d5 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -13,6 +13,8 @@ categories:
label: "Removal"
- title: "Testing"
label: "Testing"
+ - title: "Type hints"
+ label: "Type hints"
exclude-labels:
- "changelog: skip"
diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml
index 32ac6f65e76..7244315ac15 100644
--- a/.github/workflows/test-cygwin.yml
+++ b/.github/workflows/test-cygwin.yml
@@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
pull_request:
@@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
workflow_dispatch:
diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml
index eb27b4bf75b..3bb6856f6e8 100644
--- a/.github/workflows/test-docker.yml
+++ b/.github/workflows/test-docker.yml
@@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
pull_request:
@@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
workflow_dispatch:
diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml
index 115c2e9bebc..cdd51e2bb3f 100644
--- a/.github/workflows/test-mingw.yml
+++ b/.github/workflows/test-mingw.yml
@@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
pull_request:
@@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
workflow_dispatch:
diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml
index 86cd5b5fa1e..372f97fd667 100644
--- a/.github/workflows/test-windows.yml
+++ b/.github/workflows/test-windows.yml
@@ -2,11 +2,12 @@ name: Test Windows
on:
push:
+ branches:
+ - "**"
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
pull_request:
@@ -14,7 +15,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
workflow_dispatch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index aa0e2513825..05f78704bcd 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,7 +8,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
pull_request:
@@ -16,7 +15,6 @@ on:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- - ".travis.yml"
- "docs/**"
- "wheels/**"
workflow_dispatch:
@@ -86,6 +84,10 @@ jobs:
env:
GHA_PYTHON_VERSION: ${{ matrix.python-version }}
+ - name: Register gcc problem matcher
+ if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'"
+ run: echo "::add-matcher::.github/problem-matchers/gcc.json"
+
- name: Build
run: |
.ci/build.sh
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 060fc497ea7..1140aaaad52 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -30,7 +30,64 @@ env:
FORCE_COLOR: 1
jobs:
- build:
+ build-1-QEMU-emulated-wheels:
+ name: aarch64 ${{ matrix.python-version }} ${{ matrix.spec }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version:
+ - pp39
+ - pp310
+ - cp38
+ - cp39
+ - cp310
+ - cp311
+ - cp312
+ spec:
+ - manylinux2014
+ - manylinux_2_28
+ - musllinux
+ exclude:
+ - { python-version: pp39, spec: musllinux }
+ - { python-version: pp310, spec: musllinux }
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+
+ # https://github.com/docker/setup-qemu-action
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Install cibuildwheel
+ run: |
+ python3 -m pip install -r .ci/requirements-cibw.txt
+
+ - name: Build wheels
+ run: |
+ python3 -m cibuildwheel --output-dir wheelhouse
+ env:
+ # Build only the currently selected Linux architecture (so we can
+ # parallelise for speed).
+ CIBW_ARCHS: "aarch64"
+ # Likewise, select only one Python version per job to speed this up.
+ CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*"
+ # Extra options for manylinux.
+ CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }}
+ CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }}
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist-qemu-${{ matrix.python-version }}-${{ matrix.spec }}
+ path: ./wheelhouse/*.whl
+
+ build-2-native-wheels:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
@@ -39,18 +96,18 @@ jobs:
include:
- name: "macOS x86_64"
os: macos-latest
- archs: x86_64
+ cibw_arch: x86_64
macosx_deployment_target: "10.10"
- name: "macOS arm64"
os: macos-latest
- archs: arm64
+ cibw_arch: arm64
macosx_deployment_target: "11.0"
- name: "manylinux2014 and musllinux x86_64"
os: ubuntu-latest
- archs: x86_64
+ cibw_arch: x86_64
- name: "manylinux_2_28 x86_64"
os: ubuntu-latest
- archs: x86_64
+ cibw_arch: x86_64
build: "*manylinux*"
manylinux: "manylinux_2_28"
steps:
@@ -62,12 +119,15 @@ jobs:
with:
python-version: "3.x"
- - name: Build wheels
+ - name: Install cibuildwheel
run: |
python3 -m pip install -r .ci/requirements-cibw.txt
+
+ - name: Build wheels
+ run: |
python3 -m cibuildwheel --output-dir wheelhouse
env:
- CIBW_ARCHS: ${{ matrix.archs }}
+ CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BUILD: ${{ matrix.build }}
CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }}
@@ -77,22 +137,19 @@ jobs:
- uses: actions/upload-artifact@v4
with:
- name: dist-${{ matrix.os }}-${{ matrix.archs }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }}
+ name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }}
path: ./wheelhouse/*.whl
windows:
- name: Windows ${{ matrix.arch }}
+ name: Windows ${{ matrix.cibw_arch }}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- - arch: x86
- cibw_arch: x86
- - arch: x64
- cibw_arch: AMD64
- - arch: ARM64
- cibw_arch: ARM64
+ - cibw_arch: x86
+ - cibw_arch: AMD64
+ - cibw_arch: ARM64
steps:
- uses: actions/checkout@v4
@@ -106,6 +163,10 @@ jobs:
with:
python-version: "3.x"
+ - name: Install cibuildwheel
+ run: |
+ python.exe -m pip install -r .ci/requirements-cibw.txt
+
- name: Prepare for build
run: |
choco install nasm --no-progress
@@ -114,9 +175,7 @@ jobs:
# Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images
- & python.exe -m pip install -r .ci/requirements-cibw.txt
-
- & python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.arch }}
+ & python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.cibw_arch }}
shell: pwsh
- name: Build wheels
@@ -143,6 +202,7 @@ jobs:
CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd"
CIBW_CACHE_PATH: "C:\\cibw"
+ CIBW_SKIP: pp38-*
CIBW_TEST_SKIP: "*-win_arm64"
CIBW_TEST_COMMAND: 'docker run --rm
-v {project}:C:\pillow
@@ -156,13 +216,13 @@ jobs:
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
- name: dist-windows-${{ matrix.arch }}
+ name: dist-windows-${{ matrix.cibw_arch }}
path: ./wheelhouse/*.whl
- name: Upload fribidi.dll
uses: actions/upload-artifact@v4
with:
- name: fribidi-windows-${{ matrix.arch }}
+ name: fribidi-windows-${{ matrix.cibw_arch }}
path: winbuild\build\bin\fribidi*
sdist:
@@ -186,7 +246,7 @@ jobs:
pypi-publish:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
- needs: [build, windows, sdist]
+ needs: [build-1-QEMU-emulated-wheels, build-2-native-wheels, windows, sdist]
runs-on: ubuntu-latest
name: Upload release to PyPI
environment:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d1c4b801527..6adc75b4902 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,12 +1,12 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.1.7
+ rev: v0.1.9
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black-pre-commit-mirror
- rev: 23.12.0
+ rev: 23.12.1
hooks:
- id: black
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 8f8250809d7..00000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-if: tag IS present OR type = api
-
-env:
- global:
- - CIBW_ARCHS=aarch64
- - CIBW_SKIP=pp38-*
-
-language: python
-# Default Python version is usually 3.6
-python: "3.12"
-dist: jammy
-services: docker
-
-jobs:
- include:
- - name: "manylinux2014 aarch64"
- os: linux
- arch: arm64
- env:
- - CIBW_BUILD="*manylinux*"
- - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux2014
- - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux2014
- - name: "manylinux_2_28 aarch64"
- os: linux
- arch: arm64
- env:
- - CIBW_BUILD="*manylinux*"
- - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux_2_28
- - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux_2_28
- - name: "musllinux aarch64"
- os: linux
- arch: arm64
- env:
- - CIBW_BUILD="*musllinux*"
-
-install:
- - python3 -m pip install -r .ci/requirements-cibw.txt
-
-script:
- - python3 -m cibuildwheel --output-dir wheelhouse
- - ls -l "${TRAVIS_BUILD_DIR}/wheelhouse/"
-
-# Upload wheels to GitHub Releases
-deploy:
- provider: releases
- api_key: $GITHUB_RELEASE_TOKEN
- file_glob: true
- file: "${TRAVIS_BUILD_DIR}/wheelhouse/*.whl"
- on:
- repo: python-pillow/Pillow
- tags: true
- skip_cleanup: true
diff --git a/CHANGES.rst b/CHANGES.rst
index df4e11e0e48..c267ca472ce 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,9 +2,63 @@
Changelog (Pillow)
==================
-10.2.0 (unreleased)
+10.3.0 (unreleased)
-------------------
+- Added PerspectiveTransform #7699
+ [radarhere]
+
+- Add support for reading and writing grayscale PFM images #7696
+ [nulano, hugovk]
+
+- Add LCMS2 flags to ImageCms #7676
+ [nulano, radarhere, hugovk]
+
+- Rename x64 to AMD64 in winbuild #7693
+ [nulano]
+
+10.2.0 (2024-01-02)
+-------------------
+
+- Add ``keep_rgb`` option when saving JPEG to prevent conversion of RGB colorspace #7553
+ [bgilbert, radarhere]
+
+- Trim glyph size in ImageFont.getmask() #7669, #7672
+ [radarhere, nulano]
+
+- Deprecate IptcImagePlugin helpers #7664
+ [nulano, hugovk, radarhere]
+
+- Allow uncompressed TIFF images to be saved in chunks #7650
+ [radarhere]
+
+- Concatenate multiple JPEG EXIF markers #7496
+ [radarhere]
+
+- Changed IPTC tile tuple to match other plugins #7661
+ [radarhere]
+
+- Do not assign new fp attribute when exiting context manager #7566
+ [radarhere]
+
+- Support arbitrary masks for uncompressed RGB DDS images #7589
+ [radarhere, akx]
+
+- Support setting ROWSPERSTRIP tag #7654
+ [radarhere]
+
+- Apply ImageFont.MAX_STRING_LENGTH to ImageFont.getmask() #7662
+ [radarhere]
+
+- Optimise ``ImageColor`` using ``functools.lru_cache`` #7657
+ [hugovk]
+
+- Restricted environment keys for ImageMath.eval() #7655
+ [wiredfool, radarhere]
+
+- Optimise ``ImageMode.getmode`` using ``functools.lru_cache`` #7641
+ [hugovk, radarhere]
+
- Fix incorrect color blending for overlapping glyphs #7497
[ZachNagengast, nulano, radarhere]
diff --git a/LICENSE b/LICENSE
index cf65e86d734..0069eb5bcec 100644
--- a/LICENSE
+++ b/LICENSE
@@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is
Pillow is the friendly PIL fork. It is
- Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors.
+ Copyright © 2010-2024 by Jeffrey A. Clark (Alex) and contributors.
Like PIL, Pillow is licensed under the open source HPND License:
diff --git a/README.md b/README.md
index e11bd2faa1d..6ca870166a1 100644
--- a/README.md
+++ b/README.md
@@ -48,9 +48,6 @@ As of 2019, Pillow development is
-
@@ -68,10 +65,10 @@ As of 2019, Pillow development is
-
-
0.9
+ def test_rgb(self):
+ def getchannels(im):
+ return tuple(v[0] for v in im.layer)
+
+ im = hopper()
+ im_ycbcr = self.roundtrip(im)
+ assert getchannels(im_ycbcr) == (1, 2, 3)
+ assert_image_similar(im, im_ycbcr, 17)
+
+ im_rgb = self.roundtrip(im, keep_rgb=True)
+ assert getchannels(im_rgb) == (ord("R"), ord("G"), ord("B"))
+ assert_image_similar(im, im_rgb, 12)
+
@pytest.mark.parametrize(
"test_image_path",
[TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"],
@@ -423,25 +436,28 @@ def getsampling(im):
return layer[0][1:3] + layer[1][1:3] + layer[2][1:3]
# experimental API
- im = self.roundtrip(hopper(), subsampling=-1) # default
- assert getsampling(im) == (2, 2, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling=0) # 4:4:4
- assert getsampling(im) == (1, 1, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling=1) # 4:2:2
- assert getsampling(im) == (2, 1, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling=2) # 4:2:0
- assert getsampling(im) == (2, 2, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling=3) # default (undefined)
- assert getsampling(im) == (2, 2, 1, 1, 1, 1)
-
- im = self.roundtrip(hopper(), subsampling="4:4:4")
- assert getsampling(im) == (1, 1, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling="4:2:2")
- assert getsampling(im) == (2, 1, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling="4:2:0")
- assert getsampling(im) == (2, 2, 1, 1, 1, 1)
- im = self.roundtrip(hopper(), subsampling="4:1:1")
- assert getsampling(im) == (2, 2, 1, 1, 1, 1)
+ for subsampling in (-1, 3): # (default, invalid)
+ im = self.roundtrip(hopper(), subsampling=subsampling)
+ assert getsampling(im) == (2, 2, 1, 1, 1, 1)
+ for subsampling in (0, "4:4:4"):
+ im = self.roundtrip(hopper(), subsampling=subsampling)
+ assert getsampling(im) == (1, 1, 1, 1, 1, 1)
+ for subsampling in (1, "4:2:2"):
+ im = self.roundtrip(hopper(), subsampling=subsampling)
+ assert getsampling(im) == (2, 1, 1, 1, 1, 1)
+ for subsampling in (2, "4:2:0", "4:1:1"):
+ im = self.roundtrip(hopper(), subsampling=subsampling)
+ assert getsampling(im) == (2, 2, 1, 1, 1, 1)
+
+ # RGB colorspace
+ for subsampling in (-1, 0, "4:4:4"):
+ # "4:4:4" doesn't really make sense for RGB, but the conversion
+ # to an integer happens at a higher level
+ im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling)
+ assert getsampling(im) == (1, 1, 1, 1, 1, 1)
+ for subsampling in (1, "4:2:2", 2, "4:2:0", 3):
+ with pytest.raises(OSError):
+ self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling)
with pytest.raises(TypeError):
self.roundtrip(hopper(), subsampling="1:1:1")
@@ -840,6 +856,10 @@ def test_ifd_offset_exif(self):
# Act / Assert
assert im._getexif()[306] == "2017:03:13 23:03:09"
+ def test_multiple_exif(self):
+ with Image.open("Tests/images/multiple_exif.jpg") as im:
+ assert im.info["exif"] == b"Exif\x00\x00firstsecond"
+
@mark_if_feature_version(
pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing"
)
diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py
index bb49a46d376..d8e259b1cf8 100644
--- a/Tests/test_file_ppm.py
+++ b/Tests/test_file_ppm.py
@@ -6,7 +6,12 @@
from PIL import Image, PpmImagePlugin
-from .helper import assert_image_equal_tofile, assert_image_similar, hopper
+from .helper import (
+ assert_image_equal,
+ assert_image_equal_tofile,
+ assert_image_similar,
+ hopper,
+)
# sample ppm stream
TEST_FILE = "Tests/images/hopper.ppm"
@@ -84,20 +89,58 @@ def test_16bit_pgm():
def test_16bit_pgm_write(tmp_path):
with Image.open("Tests/images/16_bit_binary.pgm") as im:
- f = str(tmp_path / "temp.pgm")
- im.save(f, "PPM")
+ filename = str(tmp_path / "temp.pgm")
+ im.save(filename, "PPM")
- assert_image_equal_tofile(im, f)
+ assert_image_equal_tofile(im, filename)
def test_pnm(tmp_path):
with Image.open("Tests/images/hopper.pnm") as im:
assert_image_similar(im, hopper(), 0.0001)
- f = str(tmp_path / "temp.pnm")
- im.save(f)
+ filename = str(tmp_path / "temp.pnm")
+ im.save(filename)
+
+ assert_image_equal_tofile(im, filename)
+
+
+def test_pfm(tmp_path):
+ with Image.open("Tests/images/hopper.pfm") as im:
+ assert im.info["scale"] == 1.0
+ assert_image_equal(im, hopper("F"))
+
+ filename = str(tmp_path / "tmp.pfm")
+ im.save(filename)
+
+ assert_image_equal_tofile(im, filename)
+
+
+def test_pfm_big_endian(tmp_path):
+ with Image.open("Tests/images/hopper_be.pfm") as im:
+ assert im.info["scale"] == 2.5
+ assert_image_equal(im, hopper("F"))
- assert_image_equal_tofile(im, f)
+ filename = str(tmp_path / "tmp.pfm")
+ im.save(filename)
+
+ assert_image_equal_tofile(im, filename)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ b"Pf 1 1 NaN \0\0\0\0",
+ b"Pf 1 1 inf \0\0\0\0",
+ b"Pf 1 1 -inf \0\0\0\0",
+ b"Pf 1 1 0.0 \0\0\0\0",
+ b"Pf 1 1 -0.0 \0\0\0\0",
+ ],
+)
+def test_pfm_invalid(data):
+ with pytest.raises(ValueError):
+ with Image.open(BytesIO(data)):
+ pass
@pytest.mark.parametrize(
diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py
index 0851796d000..a50f50e5e96 100644
--- a/Tests/test_file_tiff.py
+++ b/Tests/test_file_tiff.py
@@ -484,13 +484,13 @@ def test_modify_exif(self, tmp_path):
outfile = str(tmp_path / "temp.tif")
with Image.open("Tests/images/ifd_tag_type.tiff") as im:
exif = im.getexif()
- exif[256] = 100
+ exif[264] = 100
im.save(outfile, exif=exif)
with Image.open(outfile) as im:
exif = im.getexif()
- assert exif[256] == 100
+ assert exif[264] == 100
def test_reload_exif_after_seek(self):
with Image.open("Tests/images/multipage.tiff") as im:
@@ -612,6 +612,14 @@ def test_roundtrip_tiff_uint16(self, tmp_path):
assert_image_equal_tofile(im, tmpfile)
+ def test_rowsperstrip(self, tmp_path):
+ outfile = str(tmp_path / "temp.tif")
+ im = hopper()
+ im.save(outfile, tiffinfo={278: 256})
+
+ with Image.open(outfile) as im:
+ assert im.tag_v2[278] == 256
+
def test_strip_raw(self):
infile = "Tests/images/tiff_strip_raw.tif"
with Image.open(infile) as im:
@@ -773,6 +781,27 @@ def test_get_photoshop_blocks(self):
4001,
]
+ def test_tiff_chunks(self, tmp_path):
+ tmpfile = str(tmp_path / "temp.tif")
+
+ im = hopper()
+ with open(tmpfile, "wb") as fp:
+ for y in range(0, 128, 32):
+ chunk = im.crop((0, y, 128, y + 32))
+ if y == 0:
+ chunk.save(
+ fp,
+ "TIFF",
+ tiffinfo={
+ TiffImagePlugin.IMAGEWIDTH: 128,
+ TiffImagePlugin.IMAGELENGTH: 128,
+ },
+ )
+ else:
+ fp.write(chunk.tobytes())
+
+ assert_image_equal_tofile(im, tmpfile)
+
def test_close_on_load_exclusive(self, tmp_path):
# similar to test_fd_leak, but runs on unixlike os
tmpfile = str(tmp_path / "temp.tif")
diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py
index edd57e6b54d..ee69681854e 100644
--- a/Tests/test_file_tiff_metadata.py
+++ b/Tests/test_file_tiff_metadata.py
@@ -123,6 +123,7 @@ def test_write_metadata(tmp_path):
"""Test metadata writing through the python code"""
with Image.open("Tests/images/hopper.tif") as img:
f = str(tmp_path / "temp.tiff")
+ del img.tag[278]
img.save(f, tiffinfo=img.tag)
original = img.tag_v2.named()
@@ -159,9 +160,11 @@ def test_change_stripbytecounts_tag_type(tmp_path):
out = str(tmp_path / "temp.tiff")
with Image.open("Tests/images/hopper.tif") as im:
info = im.tag_v2
+ del info[278]
# Resize the image so that STRIPBYTECOUNTS will be larger than a SHORT
im = im.resize((500, 500))
+ info[TiffImagePlugin.IMAGEWIDTH] = im.width
# STRIPBYTECOUNTS can be a SHORT or a LONG
info.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] = TiffTags.SHORT
diff --git a/Tests/test_fontfile.py b/Tests/test_fontfile.py
new file mode 100644
index 00000000000..ce1e02f63e0
--- /dev/null
+++ b/Tests/test_fontfile.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+import pytest
+
+from PIL import FontFile
+
+
+def test_save(tmp_path):
+ tempname = str(tmp_path / "temp.pil")
+
+ font = FontFile.FontFile()
+ with pytest.raises(ValueError):
+ font.save(tempname)
diff --git a/Tests/test_image.py b/Tests/test_image.py
index 615e00e40da..80f6583d8d9 100644
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -1016,6 +1016,11 @@ def test_fli_overrun2(self):
except OSError as e:
assert str(e) == "buffer overrun when reading image file"
+ def test_exit_fp(self):
+ with Image.new("L", (1, 1)) as im:
+ pass
+ assert not hasattr(im, "fp")
+
def test_close_graceful(self, caplog):
with Image.open("Tests/images/hopper.jpg") as im:
copy = im.copy()
diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index b4bf6c8df3a..5a578dba5c4 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -403,7 +403,7 @@ def test_reduce(self):
if px[2, 0] != test_color // 2:
assert test_color // 2 == px[2, 0]
- def test_nonzero_coefficients(self):
+ def test_non_zero_coefficients(self):
# regression test for the wrong coefficients calculation
# due to bug https://github.com/python-pillow/Pillow/issues/2161
im = Image.new("RGBA", (1280, 1280), (0x20, 0x40, 0x60, 0xFF))
diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py
index 15939ef647c..f5d5ab70408 100644
--- a/Tests/test_image_transform.py
+++ b/Tests/test_image_transform.py
@@ -10,18 +10,25 @@
class TestImageTransform:
def test_sanity(self):
- im = Image.new("L", (100, 100))
-
- seq = tuple(range(10))
-
- transform = ImageTransform.AffineTransform(seq[:6])
- im.transform((100, 100), transform)
- transform = ImageTransform.ExtentTransform(seq[:4])
- im.transform((100, 100), transform)
- transform = ImageTransform.QuadTransform(seq[:8])
- im.transform((100, 100), transform)
- transform = ImageTransform.MeshTransform([(seq[:4], seq[:8])])
- im.transform((100, 100), transform)
+ im = hopper()
+
+ for transform in (
+ ImageTransform.AffineTransform((1, 0, 0, 0, 1, 0)),
+ ImageTransform.PerspectiveTransform((1, 0, 0, 0, 1, 0, 0, 0)),
+ ImageTransform.ExtentTransform((0, 0) + im.size),
+ ImageTransform.QuadTransform(
+ (0, 0, 0, im.height, im.width, im.height, im.width, 0)
+ ),
+ ImageTransform.MeshTransform(
+ [
+ (
+ (0, 0) + im.size,
+ (0, 0, 0, im.height, im.width, im.height, im.width, 0),
+ )
+ ]
+ ),
+ ):
+ assert_image_equal(im, im.transform(im.size, transform))
def test_info(self):
comment = b"File written by Adobe Photoshop\xa8 4.0"
diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py
index 0dde82bd748..810394e6f5f 100644
--- a/Tests/test_imagecms.py
+++ b/Tests/test_imagecms.py
@@ -49,8 +49,8 @@ def skip_missing():
def test_sanity():
# basic smoke test.
# this mostly follows the cms_test outline.
-
- v = ImageCms.versions() # should return four strings
+ with pytest.warns(DeprecationWarning):
+ v = ImageCms.versions() # should return four strings
assert v[0] == "1.0.0 pil"
assert list(map(type, v)) == [str, str, str, str]
@@ -90,6 +90,16 @@ def test_sanity():
hopper().point(t)
+def test_flags():
+ assert ImageCms.Flags.NONE == 0
+ assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE
+ assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE
+
+ assert ImageCms.Flags.GRIDPOINTS(255) == (255 << 16)
+ assert ImageCms.Flags.GRIDPOINTS(-1) == ImageCms.Flags.GRIDPOINTS(255)
+ assert ImageCms.Flags.GRIDPOINTS(511) == ImageCms.Flags.GRIDPOINTS(255)
+
+
def test_name():
skip_missing()
# get profile information for file
@@ -627,3 +637,12 @@ def test_rgb_lab(mode):
im = Image.new("LAB", (1, 1), (255, 0, 0))
converted_im = im.convert(mode)
assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255)
+
+
+def test_deprecation() -> None:
+ with pytest.warns(DeprecationWarning):
+ assert ImageCms.DESCRIPTION.strip().startswith("pyCMS")
+ with pytest.warns(DeprecationWarning):
+ assert ImageCms.VERSION == "1.0.0 pil"
+ with pytest.warns(DeprecationWarning):
+ assert isinstance(ImageCms.FLAGS, dict)
diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py
index 6e04cddc748..807d581edf0 100644
--- a/Tests/test_imagefont.py
+++ b/Tests/test_imagefont.py
@@ -1053,11 +1053,13 @@ def test_too_many_characters(font):
with pytest.raises(ValueError):
transposed_font.getlength("A" * 1_000_001)
- default_font = ImageFont.load_default()
+ imagefont = ImageFont.ImageFont()
+ with pytest.raises(ValueError):
+ imagefont.getlength("A" * 1_000_001)
with pytest.raises(ValueError):
- default_font.getlength("A" * 1_000_001)
+ imagefont.getbbox("A" * 1_000_001)
with pytest.raises(ValueError):
- default_font.getbbox("A" * 1_000_001)
+ imagefont.getmask("A" * 1_000_001)
@pytest.mark.parametrize(
diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py
index 21b4dee3c4a..9e085510149 100644
--- a/Tests/test_imagefontpil.py
+++ b/Tests/test_imagefontpil.py
@@ -1,14 +1,22 @@
from __future__ import annotations
+import struct
import pytest
+from io import BytesIO
-from PIL import Image, ImageDraw, ImageFont, features
+from PIL import Image, ImageDraw, ImageFont, features, _util
from .helper import assert_image_equal_tofile
-pytestmark = pytest.mark.skipif(
- features.check_module("freetype2"),
- reason="PILfont superseded if FreeType is supported",
-)
+original_core = ImageFont.core
+
+
+def setup_module():
+ if features.check_module("freetype2"):
+ ImageFont.core = _util.DeferredError(ImportError)
+
+
+def teardown_module():
+ ImageFont.core = original_core
def test_default_font():
@@ -44,3 +52,25 @@ def test_textbbox():
default_font = ImageFont.load_default()
assert d.textlength("test", font=default_font) == 24
assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, 24, 11)
+
+
+def test_decompression_bomb():
+ glyph = struct.pack(">hhhhhhhhhh", 1, 0, 0, 0, 256, 256, 0, 0, 256, 256)
+ fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256)
+
+ font = ImageFont.ImageFont()
+ font._load_pilfont_data(fp, Image.new("L", (256, 256)))
+ with pytest.raises(Image.DecompressionBombError):
+ font.getmask("A" * 1_000_000)
+
+
+@pytest.mark.timeout(4)
+def test_oom():
+ glyph = struct.pack(
+ ">hhhhhhhhhh", 1, 0, -32767, -32767, 32767, 32767, -32767, -32767, 32767, 32767
+ )
+ fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256)
+
+ font = ImageFont.ImageFont()
+ font._load_pilfont_data(fp, Image.new("L", (1, 1)))
+ font.getmask("A" * 1_000_000)
diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py
index 22de86c7cab..9281de6f66a 100644
--- a/Tests/test_imagemath.py
+++ b/Tests/test_imagemath.py
@@ -64,6 +64,16 @@ def test_prevent_exec(expression):
ImageMath.eval(expression)
+def test_prevent_double_underscores():
+ with pytest.raises(ValueError):
+ ImageMath.eval("1", {"__": None})
+
+
+def test_prevent_builtins():
+ with pytest.raises(ValueError):
+ ImageMath.eval("(lambda: exec('exit()'))()", {"exec": None})
+
+
def test_logical():
assert pixel(ImageMath.eval("not A", images)) == 0
assert pixel(ImageMath.eval("A and B", images)) == "L 2"
diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py
index ec55aadf9b3..64a1785ea97 100644
--- a/Tests/test_imagemorph.py
+++ b/Tests/test_imagemorph.py
@@ -57,15 +57,6 @@ def test_str_to_img():
assert_image_equal_tofile(A, "Tests/images/morph_a.png")
-def create_lut():
- for op in ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge"):
- lb = ImageMorph.LutBuilder(op_name=op)
- lut = lb.build_lut()
- with open(f"Tests/images/{op}.lut", "wb") as f:
- f.write(lut)
-
-
-# create_lut()
@pytest.mark.parametrize(
"op", ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge")
)
diff --git a/Tests/test_util.py b/Tests/test_util.py
index 1457d85f795..4a312beb440 100644
--- a/Tests/test_util.py
+++ b/Tests/test_util.py
@@ -66,7 +66,7 @@ def test_deferred_error():
# Arrange
# Act
- thing = _util.DeferredError(ValueError("Some error text"))
+ thing = _util.DeferredError.new(ValueError("Some error text"))
# Assert
with pytest.raises(ValueError):
diff --git a/docs/COPYING b/docs/COPYING
index bc44ba388a6..73af6d99c0f 100644
--- a/docs/COPYING
+++ b/docs/COPYING
@@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is
Pillow is the friendly PIL fork. It is
- Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors
+ Copyright © 2010-2024 by Jeffrey A. Clark (Alex) and contributors
Like PIL, Pillow is licensed under the open source PIL
Software License:
diff --git a/docs/PIL.rst b/docs/PIL.rst
index fa036b9ccfe..bdbf1373d88 100644
--- a/docs/PIL.rst
+++ b/docs/PIL.rst
@@ -69,10 +69,10 @@ can be found here.
:undoc-members:
:show-inheritance:
-:mod:`~PIL.ImageTransform` Module
----------------------------------
+:mod:`~PIL.ImageMode` Module
+----------------------------
-.. automodule:: PIL.ImageTransform
+.. automodule:: PIL.ImageMode
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/about.rst b/docs/about.rst
index 872ac0ea690..cdb32ca5d10 100644
--- a/docs/about.rst
+++ b/docs/about.rst
@@ -6,15 +6,14 @@ Goals
The fork author's goal is to foster and support active development of PIL through:
-- Continuous integration testing via `GitHub Actions`_, `AppVeyor`_ and `Travis CI`_
+- Continuous integration testing via `GitHub Actions`_ and `AppVeyor`_
- Publicized development activity on `GitHub`_
- Regular releases to the `Python Package Index`_
.. _GitHub Actions: https://github.com/python-pillow/Pillow/actions
.. _AppVeyor: https://ci.appveyor.com/project/Python-pillow/pillow
-.. _Travis CI: https://app.travis-ci.com/github/python-pillow/Pillow
.. _GitHub: https://github.com/python-pillow/Pillow
-.. _Python Package Index: https://pypi.org/project/Pillow/
+.. _Python Package Index: https://pypi.org/project/pillow/
License
-------
diff --git a/docs/conf.py b/docs/conf.py
index a70dece7469..9ae7ae605fd 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -54,7 +54,7 @@
# General information about the project.
project = "Pillow (PIL Fork)"
copyright = (
- "1995-2011 Fredrik Lundh, 2010-2023 Jeffrey A. Clark (Alex) and contributors"
+ "1995-2011 Fredrik Lundh, 2010-2024 Jeffrey A. Clark (Alex) and contributors"
)
author = "Fredrik Lundh, Jeffrey A. Clark (Alex), contributors"
diff --git a/docs/deprecations.rst b/docs/deprecations.rst
index 75c0b73ebed..205fcb9abcd 100644
--- a/docs/deprecations.rst
+++ b/docs/deprecations.rst
@@ -44,6 +44,54 @@ ImageFile.raise_oserror
error codes returned by a codec's ``decode()`` method, which ImageFile already does
automatically.
+IptcImageFile helper functions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. deprecated:: 10.2.0
+
+The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant
+``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow
+12.0.0 (2025-10-15). These are undocumented helper functions intended
+for internal use, so there is no replacement. They can each be replaced
+by a single line of code using builtin functions in Python.
+
+ImageCms constants and versions() function
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. deprecated:: 10.3.0
+
+A number of constants and a function in :py:mod:`.ImageCms` have been deprecated.
+This includes a table of flags based on LittleCMS version 1 which has been
+replaced with a new class :py:class:`.ImageCms.Flags` based on LittleCMS 2 flags.
+
+============================================ ====================================================
+Deprecated Use instead
+============================================ ====================================================
+``ImageCms.DESCRIPTION`` No replacement
+``ImageCms.VERSION`` ``PIL.__version__``
+``ImageCms.FLAGS["MATRIXINPUT"]`` :py:attr:`.ImageCms.Flags.CLUT_POST_LINEARIZATION`
+``ImageCms.FLAGS["MATRIXOUTPUT"]`` :py:attr:`.ImageCms.Flags.FORCE_CLUT`
+``ImageCms.FLAGS["MATRIXONLY"]`` No replacement
+``ImageCms.FLAGS["NOWHITEONWHITEFIXUP"]`` :py:attr:`.ImageCms.Flags.NOWHITEONWHITEFIXUP`
+``ImageCms.FLAGS["NOPRELINEARIZATION"]`` :py:attr:`.ImageCms.Flags.CLUT_PRE_LINEARIZATION`
+``ImageCms.FLAGS["GUESSDEVICECLASS"]`` :py:attr:`.ImageCms.Flags.GUESSDEVICECLASS`
+``ImageCms.FLAGS["NOTCACHE"]`` :py:attr:`.ImageCms.Flags.NOCACHE`
+``ImageCms.FLAGS["NOTPRECALC"]`` :py:attr:`.ImageCms.Flags.NOOPTIMIZE`
+``ImageCms.FLAGS["NULLTRANSFORM"]`` :py:attr:`.ImageCms.Flags.NULLTRANSFORM`
+``ImageCms.FLAGS["HIGHRESPRECALC"]`` :py:attr:`.ImageCms.Flags.HIGHRESPRECALC`
+``ImageCms.FLAGS["LOWRESPRECALC"]`` :py:attr:`.ImageCms.Flags.LOWRESPRECALC`
+``ImageCms.FLAGS["GAMUTCHECK"]`` :py:attr:`.ImageCms.Flags.GAMUTCHECK`
+``ImageCms.FLAGS["WHITEBLACKCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION`
+``ImageCms.FLAGS["BLACKPOINTCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION`
+``ImageCms.FLAGS["SOFTPROOFING"]`` :py:attr:`.ImageCms.Flags.SOFTPROOFING`
+``ImageCms.FLAGS["PRESERVEBLACK"]`` :py:attr:`.ImageCms.Flags.NONEGATIVES`
+``ImageCms.FLAGS["NODEFAULTRESOURCEDEF"]`` :py:attr:`.ImageCms.Flags.NODEFAULTRESOURCEDEF`
+``ImageCms.FLAGS["GRIDPOINTS"]`` :py:attr:`.ImageCms.Flags.GRIDPOINTS()`
+``ImageCms.versions()`` :py:func:`PIL.features.version_module` with
+ ``feature="littlecms2"``, :py:data:`sys.version` or
+ :py:data:`sys.version_info`, and ``PIL.__version__``
+============================================ ====================================================
+
Removed features
----------------
@@ -107,7 +155,7 @@ Constants
.. versionremoved:: 10.0.0
A number of constants have been removed.
-Instead, ``enum.IntEnum`` classes have been added.
+Instead, :py:class:`enum.IntEnum` classes have been added.
.. note::
@@ -327,8 +375,8 @@ ImageCms.CmsProfile attributes
.. deprecated:: 3.2.0
.. versionremoved:: 8.0.0
-Some attributes in :py:class:`PIL.ImageCms.CmsProfile` have been removed. From 6.0.0,
-they issued a :py:exc:`DeprecationWarning`:
+Some attributes in :py:class:`PIL.ImageCms.core.CmsProfile` have been removed.
+From 6.0.0, they issued a :py:exc:`DeprecationWarning`:
======================== ===================================================
Removed Use instead
diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index 9cd65fd48cb..569ccb7691b 100644
--- a/docs/handbook/image-file-formats.rst
+++ b/docs/handbook/image-file-formats.rst
@@ -487,6 +487,16 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
**exif**
If present, the image will be stored with the provided raw EXIF data.
+**keep_rgb**
+ By default, libjpeg converts images with an RGB color space to YCbCr.
+ If this option is present and true, those images will be stored as RGB
+ instead.
+
+ When this option is enabled, attempting to chroma-subsample RGB images
+ with the ``subsampling`` option will raise an :py:exc:`OSError`.
+
+ .. versionadded:: 10.2.0
+
**subsampling**
If present, sets the subsampling for the encoder.
@@ -552,12 +562,13 @@ JPEG 2000
.. versionadded:: 2.4.0
-Pillow reads and writes JPEG 2000 files containing ``L``, ``LA``, ``RGB`` or
-``RGBA`` data. It can also read files containing ``YCbCr`` data, which it
-converts on read into ``RGB`` or ``RGBA`` depending on whether or not there is
-an alpha channel. Pillow supports JPEG 2000 raw codestreams (``.j2k`` files),
-as well as boxed JPEG 2000 files (``.j2p`` or ``.jpx`` files). Pillow does
-*not* support files whose components have different sampling frequencies.
+Pillow reads and writes JPEG 2000 files containing ``L``, ``LA``, ``RGB``,
+``RGBA``, or ``YCbCr`` data. When reading, ``YCbCr`` data is converted to
+``RGB`` or ``RGBA`` depending on whether or not there is an alpha channel.
+Beginning with version 8.3.0, Pillow can read (but not write) ``RGB``,
+``RGBA``, and ``YCbCr`` images with subsampled components. Pillow supports
+JPEG 2000 raw codestreams (``.j2k`` files), as well as boxed JPEG 2000 files
+(``.jp2`` or ``.jpx`` files).
When loading, if you set the ``mode`` on the image prior to the
:py:meth:`~PIL.Image.Image.load` method being invoked, you can ask Pillow to
@@ -685,6 +696,25 @@ PCX
Pillow reads and writes PCX files containing ``1``, ``L``, ``P``, or ``RGB`` data.
+PFM
+^^^
+
+.. versionadded:: 10.3.0
+
+Pillow reads and writes grayscale (Pf format) Portable FloatMap (PFM) files
+containing ``F`` data.
+
+Color (PF format) PFM files are not supported.
+
+Opening
+~~~~~~~
+
+The :py:func:`~PIL.Image.open` function sets the following
+:py:attr:`~PIL.Image.Image.info` properties:
+
+**scale**
+ The absolute value of the number stored in the *Scale Factor / Endianness* line.
+
PNG
^^^
diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst
index d79f2465f16..523e2ad7494 100644
--- a/docs/handbook/tutorial.rst
+++ b/docs/handbook/tutorial.rst
@@ -542,7 +542,7 @@ Reading from URL
from PIL import Image
from urllib.request import urlopen
- url = "https://python-pillow.org/images/pillow-logo.png"
+ url = "https://python-pillow.org/assets/images/pillow-logo.png"
img = Image.open(urlopen(url))
diff --git a/docs/index.rst b/docs/index.rst
index 4f577fe9c22..5583699193c 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -41,10 +41,6 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more `_ and by direct URL access
-eg. https://pypi.org/project/Pillow/1.0/.
+`_ and by direct URL access
+eg. https://pypi.org/project/pillow/1.0/.
diff --git a/docs/reference/ExifTags.rst b/docs/reference/ExifTags.rst
index 464ab77ea35..06965ead3f0 100644
--- a/docs/reference/ExifTags.rst
+++ b/docs/reference/ExifTags.rst
@@ -4,8 +4,9 @@
:py:mod:`~PIL.ExifTags` Module
==============================
-The :py:mod:`~PIL.ExifTags` module exposes several ``enum.IntEnum`` classes
-which provide constants and clear-text names for various well-known EXIF tags.
+The :py:mod:`~PIL.ExifTags` module exposes several :py:class:`enum.IntEnum`
+classes which provide constants and clear-text names for various well-known
+EXIF tags.
.. py:data:: Base
diff --git a/docs/reference/ImageCms.rst b/docs/reference/ImageCms.rst
index 9b9b5e7b29e..c4484cbe22a 100644
--- a/docs/reference/ImageCms.rst
+++ b/docs/reference/ImageCms.rst
@@ -8,9 +8,34 @@ The :py:mod:`~PIL.ImageCms` module provides color profile management
support using the LittleCMS2 color management engine, based on Kevin
Cazabon's PyCMS library.
+.. autoclass:: ImageCmsProfile
+ :members:
+ :special-members: __init__
.. autoclass:: ImageCmsTransform
+ :members:
+ :undoc-members:
+ :show-inheritance:
.. autoexception:: PyCMSError
+Constants
+---------
+
+.. autoclass:: Intent
+ :members:
+ :member-order: bysource
+ :undoc-members:
+ :show-inheritance:
+.. autoclass:: Direction
+ :members:
+ :member-order: bysource
+ :undoc-members:
+ :show-inheritance:
+.. autoclass:: Flags
+ :members:
+ :member-order: bysource
+ :undoc-members:
+ :show-inheritance:
+
Functions
---------
@@ -37,13 +62,15 @@ CmsProfile
----------
The ICC color profiles are wrapped in an instance of the class
-:py:class:`CmsProfile`. The specification ICC.1:2010 contains more
+:py:class:`~core.CmsProfile`. The specification ICC.1:2010 contains more
information about the meaning of the values in ICC profiles.
For convenience, all XYZ-values are also given as xyY-values (so they
can be easily displayed in a chromaticity diagram, for example).
+.. py:currentmodule:: PIL.ImageCms.core
.. py:class:: CmsProfile
+ :canonical: PIL._imagingcms.CmsProfile
.. py:attribute:: creation_date
:type: Optional[datetime.datetime]
diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst
index 0b94032d5f8..db2987eb081 100644
--- a/docs/reference/ImageGrab.rst
+++ b/docs/reference/ImageGrab.rst
@@ -11,9 +11,9 @@ or the clipboard to a PIL image memory.
.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None)
- Take a snapshot of the screen. The pixels inside the bounding box are
- returned as an "RGBA" on macOS, or an "RGB" image otherwise.
- If the bounding box is omitted, the entire screen is copied.
+ Take a snapshot of the screen. The pixels inside the bounding box are returned as
+ an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted,
+ the entire screen is copied, and on macOS, it will be at 2x if on a Retina screen.
On Linux, if ``xdisplay`` is ``None`` and the default X11 display does not return
a snapshot of the screen, ``gnome-screenshot`` will be used as fallback if it is
@@ -22,7 +22,10 @@ or the clipboard to a PIL image memory.
.. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux)
:param bbox: What region to copy. Default is the entire screen.
- Note that on Windows OS, the top-left point may be negative if ``all_screens=True`` is used.
+ On macOS, this is not increased to 2x for Retina screens, so the full
+ width of a Retina screen would be 1440, not 2880.
+ On Windows, the top-left point may be negative if ``all_screens=True``
+ is used.
:param include_layered_windows: Includes layered windows. Windows OS only.
.. versionadded:: 6.1.0
diff --git a/docs/reference/ImageTransform.rst b/docs/reference/ImageTransform.rst
new file mode 100644
index 00000000000..5b0a5ce49dc
--- /dev/null
+++ b/docs/reference/ImageTransform.rst
@@ -0,0 +1,40 @@
+
+.. py:module:: PIL.ImageTransform
+.. py:currentmodule:: PIL.ImageTransform
+
+:py:mod:`~PIL.ImageTransform` Module
+====================================
+
+The :py:mod:`~PIL.ImageTransform` module contains implementations of
+:py:class:`~PIL.Image.ImageTransformHandler` for some of the builtin
+:py:class:`.Image.Transform` methods.
+
+.. autoclass:: Transform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: AffineTransform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: PerspectiveTransform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: ExtentTransform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: QuadTransform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: MeshTransform
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/reference/index.rst b/docs/reference/index.rst
index 5d6affa94ad..82c75e373ad 100644
--- a/docs/reference/index.rst
+++ b/docs/reference/index.rst
@@ -25,6 +25,7 @@ Reference
ImageShow
ImageStat
ImageTk
+ ImageTransform
ImageWin
ExifTags
TiffTags
diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst
index 363a67d9b02..f2932c32200 100644
--- a/docs/reference/internal_modules.rst
+++ b/docs/reference/internal_modules.rst
@@ -25,6 +25,19 @@ Internal Modules
:undoc-members:
:show-inheritance:
+:mod:`~PIL._typing` Module
+--------------------------
+
+.. module:: PIL._typing
+
+Provides a convenient way to import type hints that are not available
+on some Python versions.
+
+.. py:data:: TypeGuard
+ :value: typing.TypeGuard
+
+ See :py:obj:`typing.TypeGuard`.
+
:mod:`~PIL._util` Module
------------------------
diff --git a/docs/releasenotes/10.0.0.rst b/docs/releasenotes/10.0.0.rst
index a3f238119f0..705ca04152f 100644
--- a/docs/releasenotes/10.0.0.rst
+++ b/docs/releasenotes/10.0.0.rst
@@ -43,7 +43,7 @@ Constants
^^^^^^^^^
A number of constants have been removed.
-Instead, ``enum.IntEnum`` classes have been added.
+Instead, :py:class:`enum.IntEnum` classes have been added.
===================================================== ============================================================
Removed Use instead
diff --git a/docs/releasenotes/10.2.0.rst b/docs/releasenotes/10.2.0.rst
index 9883f10baf3..c3947f64c2e 100644
--- a/docs/releasenotes/10.2.0.rst
+++ b/docs/releasenotes/10.2.0.rst
@@ -1,14 +1,6 @@
10.2.0
------
-Backwards Incompatible Changes
-==============================
-
-TODO
-^^^^
-
-TODO
-
Deprecations
============
@@ -20,10 +12,14 @@ ImageFile.raise_oserror
error codes returned by a codec's ``decode()`` method, which ImageFile already does
automatically.
-TODO
-^^^^
+IptcImageFile helper functions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-TODO
+The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant
+``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow
+12.0.0 (2025-10-15). These are undocumented helper functions intended
+for internal use, so there is no replacement. They can each be replaced
+by a single line of code using builtin functions in Python.
API Changes
===========
@@ -46,6 +42,14 @@ Added DdsImagePlugin enums
:py:class:`~PIL.DdsImagePlugin.DXGI_FORMAT` and :py:class:`~PIL.DdsImagePlugin.D3DFMT`
enums have been added to :py:class:`PIL.DdsImagePlugin`.
+JPEG RGB color space
+^^^^^^^^^^^^^^^^^^^^
+
+When saving JPEG files, ``keep_rgb`` can now be set to ``True``. This will store RGB
+images in the RGB color space instead of being converted to YCbCr automatically by
+libjpeg. When this option is enabled, attempting to chroma-subsample RGB images with
+the ``subsampling`` option will raise an :py:exc:`OSError`.
+
JPEG restart marker interval
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -62,10 +66,34 @@ output only the quantization and Huffman tables for the image.
Security
========
-TODO
-^^^^
+ImageFont.getmask: Applied ImageFont.MAX_STRING_LENGTH
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+To protect against potential DOS attacks when using arbitrary strings as text input,
+Pillow will now raise a :py:exc:`ValueError` if the number of characters passed into
+:py:meth:`PIL.ImageFont.ImageFont.getmask` is over a certain limit,
+:py:data:`PIL.ImageFont.MAX_STRING_LENGTH`.
+
+This threshold can be changed by setting :py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. It
+can be disabled by setting ``ImageFont.MAX_STRING_LENGTH = None``.
-TODO
+A decompression bomb check has also been added to
+:py:meth:`PIL.ImageFont.ImageFont.getmask`.
+
+ImageFont.getmask: Trim glyph size
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+To protect against potential DOS attacks when using PIL fonts,
+:py:class:`PIL.ImageFont.ImageFont` now trims the size of individual glyphs so that
+they do not extend beyond the bitmap image.
+
+ImageMath.eval: Restricted environment keys
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:cve:`2023-50447`: If an attacker has control over the keys passed to the
+``environment`` argument of :py:meth:`PIL.ImageMath.eval`, they may be able to execute
+arbitrary code. To prevent this, keys matching the names of builtins and keys
+containing double underscores will now raise a :py:exc:`ValueError`.
Other Changes
=============
@@ -78,16 +106,56 @@ Support has been added to read the BC4U format of DDS images.
Support has also been added to read DX10 BC1 and BC4, whether UNORM or
TYPELESS.
+Support arbitrary masks for uncompressed RGB DDS images
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+All masks are now supported when reading DDS images with uncompressed RGB data,
+allowing for bit counts other than 24 and 32.
+
+Saving TIFF tag RowsPerStrip
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+When saving TIFF images, the TIFF tag RowsPerStrip can now be one of the tags set by
+the user, rather than always being calculated by Pillow.
+
+Optimized ImageColor.getrgb and getcolor
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The color calculations of :py:attr:`~PIL.ImageColor.getrgb` and
+:py:attr:`~PIL.ImageColor.getcolor` are now cached using
+:py:func:`functools.lru_cache`. Cached calls of ``getrgb`` are 3.1 - 91.4 times
+as fast and ``getcolor`` are 5.1 - 19.6 times as fast.
+
+Optimized ImageMode.getmode
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The lookups made by :py:attr:`~PIL.ImageMode.getmode` are now cached using
+:py:func:`functools.lru_cache` instead of a custom cache. Cached calls are 1.2 times as
+fast.
+
Optimized ImageStat.Stat count and extrema
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Calculating the :py:attr:`~PIL.ImageStat.Stat.count` and
:py:attr:`~PIL.ImageStat.Stat.extrema` statistics is now faster. After the
-histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3x as fast
-on average and ``st.extrema`` is 12x as fast on average.
+histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3 times as fast on
+average and ``st.extrema`` is 12 times as fast on average.
Encoder errors now report error detail as string
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
:py:exc:`OSError` exceptions from image encoders now include a textual description of
the error instead of a numeric error code.
+
+Type hints
+^^^^^^^^^^
+
+Work has begun to add type annotations to Pillow, including:
+
+* :py:mod:`~PIL.ContainerIO`
+* :py:mod:`~PIL.FontFile`, :py:mod:`~PIL.BdfFontFile` and :py:mod:`~PIL.PcfFontFile`
+* :py:mod:`~PIL.ImageChops`
+* :py:mod:`~PIL.ImageMode`
+* :py:mod:`~PIL.ImageSequence`
+* :py:mod:`~PIL.ImageTransform`
+* :py:mod:`~PIL.TarIO`
diff --git a/docs/releasenotes/10.3.0.rst b/docs/releasenotes/10.3.0.rst
new file mode 100644
index 00000000000..8772a382dc2
--- /dev/null
+++ b/docs/releasenotes/10.3.0.rst
@@ -0,0 +1,81 @@
+10.3.0
+------
+
+Backwards Incompatible Changes
+==============================
+
+TODO
+^^^^
+
+Deprecations
+============
+
+ImageCms constants and versions() function
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+A number of constants and a function in :py:mod:`.ImageCms` have been deprecated.
+This includes a table of flags based on LittleCMS version 1 which has been replaced
+with a new class :py:class:`.ImageCms.Flags` based on LittleCMS 2 flags.
+
+============================================ ====================================================
+Deprecated Use instead
+============================================ ====================================================
+``ImageCms.DESCRIPTION`` No replacement
+``ImageCms.VERSION`` ``PIL.__version__``
+``ImageCms.FLAGS["MATRIXINPUT"]`` :py:attr:`.ImageCms.Flags.CLUT_POST_LINEARIZATION`
+``ImageCms.FLAGS["MATRIXOUTPUT"]`` :py:attr:`.ImageCms.Flags.FORCE_CLUT`
+``ImageCms.FLAGS["MATRIXONLY"]`` No replacement
+``ImageCms.FLAGS["NOWHITEONWHITEFIXUP"]`` :py:attr:`.ImageCms.Flags.NOWHITEONWHITEFIXUP`
+``ImageCms.FLAGS["NOPRELINEARIZATION"]`` :py:attr:`.ImageCms.Flags.CLUT_PRE_LINEARIZATION`
+``ImageCms.FLAGS["GUESSDEVICECLASS"]`` :py:attr:`.ImageCms.Flags.GUESSDEVICECLASS`
+``ImageCms.FLAGS["NOTCACHE"]`` :py:attr:`.ImageCms.Flags.NOCACHE`
+``ImageCms.FLAGS["NOTPRECALC"]`` :py:attr:`.ImageCms.Flags.NOOPTIMIZE`
+``ImageCms.FLAGS["NULLTRANSFORM"]`` :py:attr:`.ImageCms.Flags.NULLTRANSFORM`
+``ImageCms.FLAGS["HIGHRESPRECALC"]`` :py:attr:`.ImageCms.Flags.HIGHRESPRECALC`
+``ImageCms.FLAGS["LOWRESPRECALC"]`` :py:attr:`.ImageCms.Flags.LOWRESPRECALC`
+``ImageCms.FLAGS["GAMUTCHECK"]`` :py:attr:`.ImageCms.Flags.GAMUTCHECK`
+``ImageCms.FLAGS["WHITEBLACKCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION`
+``ImageCms.FLAGS["BLACKPOINTCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION`
+``ImageCms.FLAGS["SOFTPROOFING"]`` :py:attr:`.ImageCms.Flags.SOFTPROOFING`
+``ImageCms.FLAGS["PRESERVEBLACK"]`` :py:attr:`.ImageCms.Flags.NONEGATIVES`
+``ImageCms.FLAGS["NODEFAULTRESOURCEDEF"]`` :py:attr:`.ImageCms.Flags.NODEFAULTRESOURCEDEF`
+``ImageCms.FLAGS["GRIDPOINTS"]`` :py:attr:`.ImageCms.Flags.GRIDPOINTS()`
+``ImageCms.versions()`` :py:func:`PIL.features.version_module` with
+ ``feature="littlecms2"``, :py:data:`sys.version` or
+ :py:data:`sys.version_info`, and ``PIL.__version__``
+============================================ ====================================================
+
+API Changes
+===========
+
+TODO
+^^^^
+
+TODO
+
+API Additions
+=============
+
+Added PerspectiveTransform
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:py:class:`~PIL.ImageTransform.PerspectiveTransform` has been added, meaning
+that all of the :py:data:`~PIL.Image.Transform` values now have a corresponding
+subclass of :py:class:`~PIL.ImageTransform.Transform`.
+
+Security
+========
+
+TODO
+^^^^
+
+TODO
+
+Other Changes
+=============
+
+Portable FloatMap (PFM) images
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Support has been added for reading and writing grayscale (Pf format)
+Portable FloatMap (PFM) files containing ``F`` data.
diff --git a/docs/releasenotes/8.0.0.rst b/docs/releasenotes/8.0.0.rst
index 2bf299dd3d8..1fc245c9a3c 100644
--- a/docs/releasenotes/8.0.0.rst
+++ b/docs/releasenotes/8.0.0.rst
@@ -30,7 +30,7 @@ Image.fromstring, im.fromstring and im.tostring
ImageCms.CmsProfile attributes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-Some attributes in :py:class:`PIL.ImageCms.CmsProfile` have been removed:
+Some attributes in :py:class:`PIL.ImageCms.core.CmsProfile` have been removed:
======================== ===================================================
Removed Use instead
diff --git a/docs/releasenotes/9.1.0.rst b/docs/releasenotes/9.1.0.rst
index 02da702a799..6400218f467 100644
--- a/docs/releasenotes/9.1.0.rst
+++ b/docs/releasenotes/9.1.0.rst
@@ -51,7 +51,7 @@ Constants
^^^^^^^^^
A number of constants have been deprecated and will be removed in Pillow 10.0.0
-(2023-07-01). Instead, ``enum.IntEnum`` classes have been added.
+(2023-07-01). Instead, :py:class:`enum.IntEnum` classes have been added.
.. note::
diff --git a/docs/releasenotes/9.3.0.rst b/docs/releasenotes/9.3.0.rst
index fde2faae3a7..16075ce95ec 100644
--- a/docs/releasenotes/9.3.0.rst
+++ b/docs/releasenotes/9.3.0.rst
@@ -33,8 +33,9 @@ Added ExifTags enums
^^^^^^^^^^^^^^^^^^^^
The data from :py:data:`~PIL.ExifTags.TAGS` and
-:py:data:`~PIL.ExifTags.GPSTAGS` is now also exposed as ``enum.IntEnum``
-classes: :py:data:`~PIL.ExifTags.Base` and :py:data:`~PIL.ExifTags.GPS`.
+:py:data:`~PIL.ExifTags.GPSTAGS` is now also exposed as
+:py:class:`enum.IntEnum` classes: :py:data:`~PIL.ExifTags.Base` and
+:py:data:`~PIL.ExifTags.GPS`.
Security
diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst
index d8034853cc2..e86f8082b48 100644
--- a/docs/releasenotes/index.rst
+++ b/docs/releasenotes/index.rst
@@ -14,6 +14,7 @@ expected to be backported to earlier versions.
.. toctree::
:maxdepth: 2
+ 10.3.0
10.2.0
10.1.0
10.0.1
diff --git a/pyproject.toml b/pyproject.toml
index 193e8c9b247..da2537b2137 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,6 +65,9 @@ tests = [
"pytest-cov",
"pytest-timeout",
]
+typing = [
+ 'typing-extensions; python_version < "3.10"',
+]
xmp = [
"defusedxml",
]
@@ -117,6 +120,8 @@ extend-ignore = [
[tool.ruff.per-file-ignores]
"Tests/*.py" = ["I001"]
+"Tests/oss-fuzz/fuzz_font.py" = ["I002"]
+"Tests/oss-fuzz/fuzz_pillow.py" = ["I002"]
[tool.ruff.isort]
known-first-party = ["PIL"]
@@ -141,9 +146,6 @@ exclude = [
'^src/PIL/DdsImagePlugin.py$',
'^src/PIL/FpxImagePlugin.py$',
'^src/PIL/Image.py$',
- '^src/PIL/ImageCms.py$',
- '^src/PIL/ImageFile.py$',
- '^src/PIL/ImageFont.py$',
'^src/PIL/ImageMath.py$',
'^src/PIL/ImageMorph.py$',
'^src/PIL/ImageQt.py$',
diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py
index b12ddc2d4b4..e3eda4fe98c 100644
--- a/src/PIL/BdfFontFile.py
+++ b/src/PIL/BdfFontFile.py
@@ -22,6 +22,8 @@
"""
from __future__ import annotations
+from typing import BinaryIO
+
from . import FontFile, Image
bdf_slant = {
@@ -36,7 +38,17 @@
bdf_spacing = {"P": "Proportional", "M": "Monospaced", "C": "Cell"}
-def bdf_char(f):
+def bdf_char(
+ f: BinaryIO,
+) -> (
+ tuple[
+ str,
+ int,
+ tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
+ Image.Image,
+ ]
+ | None
+):
# skip to STARTCHAR
while True:
s = f.readline()
@@ -56,13 +68,12 @@ def bdf_char(f):
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
# load bitmap
- bitmap = []
+ bitmap = bytearray()
while True:
s = f.readline()
if not s or s[:7] == b"ENDCHAR":
break
- bitmap.append(s[:-1])
- bitmap = b"".join(bitmap)
+ bitmap += s[:-1]
# The word BBX
# followed by the width in x (BBw), height in y (BBh),
@@ -92,7 +103,7 @@ def bdf_char(f):
class BdfFontFile(FontFile.FontFile):
"""Font file plugin for the X11 BDF format."""
- def __init__(self, fp):
+ def __init__(self, fp: BinaryIO):
super().__init__()
s = fp.readline()
diff --git a/src/PIL/ContainerIO.py b/src/PIL/ContainerIO.py
index 64d04242639..0035296a45c 100644
--- a/src/PIL/ContainerIO.py
+++ b/src/PIL/ContainerIO.py
@@ -16,15 +16,16 @@
from __future__ import annotations
import io
+from typing import IO, AnyStr, Generic, Literal
-class ContainerIO:
+class ContainerIO(Generic[AnyStr]):
"""
A file object that provides read access to a part of an existing
file (for example a TAR file).
"""
- def __init__(self, file, offset, length) -> None:
+ def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
"""
Create file object.
@@ -32,7 +33,7 @@ def __init__(self, file, offset, length) -> None:
:param offset: Start of region, in bytes.
:param length: Size of region, in bytes.
"""
- self.fh = file
+ self.fh: IO[AnyStr] = file
self.pos = 0
self.offset = offset
self.length = length
@@ -41,10 +42,10 @@ def __init__(self, file, offset, length) -> None:
##
# Always false.
- def isatty(self):
+ def isatty(self) -> bool:
return False
- def seek(self, offset, mode=io.SEEK_SET):
+ def seek(self, offset: int, mode: Literal[0, 1, 2] = io.SEEK_SET) -> None:
"""
Move file pointer.
@@ -63,7 +64,7 @@ def seek(self, offset, mode=io.SEEK_SET):
self.pos = max(0, min(self.pos, self.length))
self.fh.seek(self.offset + self.pos)
- def tell(self):
+ def tell(self) -> int:
"""
Get current file pointer.
@@ -71,7 +72,7 @@ def tell(self):
"""
return self.pos
- def read(self, n=0):
+ def read(self, n: int = 0) -> AnyStr:
"""
Read data.
@@ -84,17 +85,17 @@ def read(self, n=0):
else:
n = self.length - self.pos
if not n: # EOF
- return b"" if "b" in self.fh.mode else ""
+ return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
self.pos = self.pos + n
return self.fh.read(n)
- def readline(self):
+ def readline(self) -> AnyStr:
"""
Read a line of text.
:returns: An 8-bit string.
"""
- s = b"" if "b" in self.fh.mode else ""
+ s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
newline_character = b"\n" if "b" in self.fh.mode else "\n"
while True:
c = self.read(1)
@@ -105,7 +106,7 @@ def readline(self):
break
return s
- def readlines(self):
+ def readlines(self) -> list[AnyStr]:
"""
Read multiple lines of text.
diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py
index 5b6ac2ead50..eb4c8f557af 100644
--- a/src/PIL/DdsImagePlugin.py
+++ b/src/PIL/DdsImagePlugin.py
@@ -18,6 +18,7 @@
from . import Image, ImageFile, ImagePalette
from ._binary import i32le as i32
+from ._binary import o8
from ._binary import o32le as o32
# Magic ("DDS ")
@@ -341,6 +342,7 @@ def _open(self):
flags, height, width = struct.unpack("<3I", header.read(12))
self._size = (width, height)
+ extents = (0, 0) + self.size
pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
struct.unpack("<11I", header.read(44)) # reserved
@@ -351,22 +353,16 @@ def _open(self):
rawmode = None
if pfflags & DDPF.RGB:
# Texture contains uncompressed RGB data
- masks = struct.unpack("<4I", header.read(16))
- masks = {mask: ["R", "G", "B", "A"][i] for i, mask in enumerate(masks)}
- if bitcount == 24:
- self._mode = "RGB"
- rawmode = masks[0x000000FF] + masks[0x0000FF00] + masks[0x00FF0000]
- elif bitcount == 32 and pfflags & DDPF.ALPHAPIXELS:
+ if pfflags & DDPF.ALPHAPIXELS:
self._mode = "RGBA"
- rawmode = (
- masks[0x000000FF]
- + masks[0x0000FF00]
- + masks[0x00FF0000]
- + masks[0xFF000000]
- )
+ mask_count = 4
else:
- msg = f"Unsupported bitcount {bitcount} for {pfflags}"
- raise OSError(msg)
+ self._mode = "RGB"
+ mask_count = 3
+
+ masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4))
+ self.tile = [("dds_rgb", extents, 0, (bitcount, masks))]
+ return
elif pfflags & DDPF.LUMINANCE:
if bitcount == 8:
self._mode = "L"
@@ -464,7 +460,6 @@ def _open(self):
msg = f"Unknown pixel format flags {pfflags}"
raise NotImplementedError(msg)
- extents = (0, 0) + self.size
if n:
self.tile = [
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
@@ -476,6 +471,39 @@ def load_seek(self, pos):
pass
+class DdsRgbDecoder(ImageFile.PyDecoder):
+ _pulls_fd = True
+
+ def decode(self, buffer):
+ bitcount, masks = self.args
+
+ # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
+ # Calculate how many zeros each mask is padded with
+ mask_offsets = []
+ # And the maximum value of each channel without the padding
+ mask_totals = []
+ for mask in masks:
+ offset = 0
+ if mask != 0:
+ while mask >> (offset + 1) << (offset + 1) == mask:
+ offset += 1
+ mask_offsets.append(offset)
+ mask_totals.append(mask >> offset)
+
+ data = bytearray()
+ bytecount = bitcount // 8
+ while len(data) < self.state.xsize * self.state.ysize * len(masks):
+ value = int.from_bytes(self.fd.read(bytecount), "little")
+ for i, mask in enumerate(masks):
+ masked_value = value & mask
+ # Remove the zero padding, and scale it to 8 bits
+ data += o8(
+ int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
+ )
+ self.set_as_raw(bytes(data))
+ return -1, 0
+
+
def _save(im, fp, filename):
if im.mode not in ("RGB", "RGBA", "L", "LA"):
msg = f"cannot write mode {im.mode} as DDS"
@@ -533,5 +561,6 @@ def _accept(prefix):
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
+Image.register_decoder("dds_rgb", DdsRgbDecoder)
Image.register_save(DdsImageFile.format, _save)
Image.register_extension(DdsImageFile.format, ".dds")
diff --git a/src/PIL/FontFile.py b/src/PIL/FontFile.py
index 9621770e2a9..3ec1ae819fc 100644
--- a/src/PIL/FontFile.py
+++ b/src/PIL/FontFile.py
@@ -16,13 +16,16 @@
from __future__ import annotations
import os
+from typing import BinaryIO
from . import Image, _binary
WIDTH = 800
-def puti16(fp, values):
+def puti16(
+ fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
+) -> None:
"""Write network order (big-endian) 16-bit sequence"""
for v in values:
if v < 0:
@@ -33,16 +36,34 @@ def puti16(fp, values):
class FontFile:
"""Base class for raster font file handlers."""
- bitmap = None
-
- def __init__(self):
- self.info = {}
- self.glyph = [None] * 256
-
- def __getitem__(self, ix):
+ bitmap: Image.Image | None = None
+
+ def __init__(self) -> None:
+ self.info: dict[bytes, bytes | int] = {}
+ self.glyph: list[
+ tuple[
+ tuple[int, int],
+ tuple[int, int, int, int],
+ tuple[int, int, int, int],
+ Image.Image,
+ ]
+ | None
+ ] = [None] * 256
+
+ def __getitem__(
+ self, ix: int
+ ) -> (
+ tuple[
+ tuple[int, int],
+ tuple[int, int, int, int],
+ tuple[int, int, int, int],
+ Image.Image,
+ ]
+ | None
+ ):
return self.glyph[ix]
- def compile(self):
+ def compile(self) -> None:
"""Create metrics and bitmap"""
if self.bitmap:
@@ -51,7 +72,7 @@ def compile(self):
# create bitmap large enough to hold all data
h = w = maxwidth = 0
lines = 1
- for glyph in self:
+ for glyph in self.glyph:
if glyph:
d, dst, src, im = glyph
h = max(h, src[3] - src[1])
@@ -65,13 +86,16 @@ def compile(self):
ysize = lines * h
if xsize == 0 and ysize == 0:
- return ""
+ return
self.ysize = h
# paste glyphs into bitmap
self.bitmap = Image.new("1", (xsize, ysize))
- self.metrics = [None] * 256
+ self.metrics: list[
+ tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
+ | None
+ ] = [None] * 256
x = y = 0
for i in range(256):
glyph = self[i]
@@ -88,12 +112,15 @@ def compile(self):
self.bitmap.paste(im.crop(src), s)
self.metrics[i] = d, dst, s
- def save(self, filename):
+ def save(self, filename: str) -> None:
"""Save font"""
self.compile()
# font data
+ if not self.bitmap:
+ msg = "No bitmap created"
+ raise ValueError(msg)
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
# font metrics
@@ -104,6 +131,6 @@ def save(self, filename):
for id in range(256):
m = self.metrics[id]
if not m:
- puti16(fp, [0] * 10)
+ puti16(fp, (0,) * 10)
else:
puti16(fp, m[0] + m[1] + m[2])
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
index b9e2a41291d..a44528df643 100644
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -92,7 +92,7 @@ class DecompressionBombError(Exception):
raise ImportError(msg)
except ImportError as v:
- core = DeferredError(ImportError("The _imaging C module is not installed."))
+ core = DeferredError.new(ImportError("The _imaging C module is not installed."))
# Explanations for ways that we know we might have an import error
if str(v).startswith("Module use of python"):
# The _imaging C module is present, but not compiled for
@@ -530,15 +530,19 @@ def _new(self, im):
def __enter__(self):
return self
+ def _close_fp(self):
+ if getattr(self, "_fp", False):
+ if self._fp != self.fp:
+ self._fp.close()
+ self._fp = DeferredError(ValueError("Operation on closed image"))
+ if self.fp:
+ self.fp.close()
+
def __exit__(self, *args):
- if hasattr(self, "fp") and getattr(self, "_exclusive_fp", False):
- if getattr(self, "_fp", False):
- if self._fp != self.fp:
- self._fp.close()
- self._fp = DeferredError(ValueError("Operation on closed image"))
- if self.fp:
- self.fp.close()
- self.fp = None
+ if hasattr(self, "fp"):
+ if getattr(self, "_exclusive_fp", False):
+ self._close_fp()
+ self.fp = None
def close(self):
"""
@@ -554,12 +558,7 @@ def close(self):
"""
if hasattr(self, "fp"):
try:
- if getattr(self, "_fp", False):
- if self._fp != self.fp:
- self._fp.close()
- self._fp = DeferredError(ValueError("Operation on closed image"))
- if self.fp:
- self.fp.close()
+ self._close_fp()
self.fp = None
except Exception as msg:
logger.debug("Error closing: %s", msg)
@@ -1194,7 +1193,7 @@ def copy(self) -> Image:
__copy__ = copy
- def crop(self, box=None):
+ def crop(self, box=None) -> Image:
"""
Returns a rectangular region from this image. The box is a
4-tuple defining the left, upper, right, and lower pixel
@@ -1659,7 +1658,7 @@ def entropy(self, mask=None, extrema=None):
return self.im.entropy(extrema)
return self.im.entropy()
- def paste(self, im, box=None, mask=None):
+ def paste(self, im, box=None, mask=None) -> None:
"""
Pastes another image into this image. The box argument is either
a 2-tuple giving the upper left corner, a 4-tuple defining the
@@ -2352,7 +2351,7 @@ def transform(x, y, matrix):
(w, h), Transform.AFFINE, matrix, resample, fillcolor=fillcolor
)
- def save(self, fp, format=None, **params):
+ def save(self, fp, format=None, **params) -> None:
"""
Saves this image under the given filename. If no format is
specified, the format to use is determined from the filename
@@ -2644,7 +2643,7 @@ def transform(
resample=Resampling.NEAREST,
fill=1,
fillcolor=None,
- ):
+ ) -> Image:
"""
Transforms this image. This method creates a new image with the
given size, and the same mode as the original, and copies data
@@ -2667,6 +2666,10 @@ class Example(Image.ImageTransformHandler):
def transform(self, size, data, resample, fill=1):
# Return result
+ Implementations of :py:class:`~PIL.Image.ImageTransformHandler`
+ for some of the :py:class:`Transform` methods are provided
+ in :py:mod:`~PIL.ImageTransform`.
+
It may also be an object with a ``method.getdata`` method
that returns a tuple supplying new ``method`` and ``data`` values::
@@ -2903,7 +2906,7 @@ def _check_size(size):
return True
-def new(mode, size, color=0):
+def new(mode, size, color=0) -> Image:
"""
Creates a new image with the given mode and size.
@@ -2942,7 +2945,7 @@ def new(mode, size, color=0):
return im._new(core.fill(mode, size, color))
-def frombytes(mode, size, data, decoder_name="raw", *args):
+def frombytes(mode, size, data, decoder_name="raw", *args) -> Image:
"""
Creates a copy of an image memory from pixel data in a buffer.
@@ -3191,7 +3194,7 @@ def _decompression_bomb_check(size):
)
-def open(fp, mode="r", formats=None):
+def open(fp, mode="r", formats=None) -> Image:
"""
Opens and identifies the given image file.
@@ -3416,7 +3419,7 @@ def merge(mode, bands):
# Plugin registry
-def register_open(id, factory, accept=None):
+def register_open(id, factory, accept=None) -> None:
"""
Register an image file plugin. This function should not be used
in application code.
@@ -3470,7 +3473,7 @@ def register_save_all(id, driver):
SAVE_ALL[id.upper()] = driver
-def register_extension(id, extension):
+def register_extension(id, extension) -> None:
"""
Registers an image extension. This function should not be
used in application code.
diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py
index 12634e21326..6116b291dcb 100644
--- a/src/PIL/ImageCms.py
+++ b/src/PIL/ImageCms.py
@@ -4,6 +4,9 @@
# Optional color management support, based on Kevin Cazabon's PyCMS
# library.
+# Originally released under LGPL. Graciously donated to PIL in
+# March 2009, for distribution under the standard PIL license
+
# History:
# 2009-03-08 fl Added to PIL.
@@ -16,10 +19,14 @@
# below for the original description.
from __future__ import annotations
+import operator
import sys
-from enum import IntEnum
+from enum import IntEnum, IntFlag
+from functools import reduce
+from typing import Any
from . import Image
+from ._deprecate import deprecate
from ._version import __version__
try:
@@ -29,9 +36,9 @@
# anything in core.
from ._util import DeferredError
- _imagingcms = DeferredError(ex)
+ _imagingcms = DeferredError.new(ex)
-DESCRIPTION = """
+_DESCRIPTION = """
pyCMS
a Python / PIL interface to the littleCMS ICC Color Management System
@@ -94,7 +101,22 @@
"""
-VERSION = "1.0.0 pil"
+_VERSION = "1.0.0 pil"
+
+
+def __getattr__(name: str) -> Any:
+ if name == "DESCRIPTION":
+ deprecate("PIL.ImageCms.DESCRIPTION", 12)
+ return _DESCRIPTION
+ elif name == "VERSION":
+ deprecate("PIL.ImageCms.VERSION", 12)
+ return _VERSION
+ elif name == "FLAGS":
+ deprecate("PIL.ImageCms.FLAGS", 12, "PIL.ImageCms.Flags")
+ return _FLAGS
+ msg = f"module '{__name__}' has no attribute '{name}'"
+ raise AttributeError(msg)
+
# --------------------------------------------------------------------.
@@ -120,7 +142,70 @@ class Direction(IntEnum):
#
# flags
-FLAGS = {
+
+class Flags(IntFlag):
+ """Flags and documentation are taken from ``lcms2.h``."""
+
+ NONE = 0
+ NOCACHE = 0x0040
+ """Inhibit 1-pixel cache"""
+ NOOPTIMIZE = 0x0100
+ """Inhibit optimizations"""
+ NULLTRANSFORM = 0x0200
+ """Don't transform anyway"""
+ GAMUTCHECK = 0x1000
+ """Out of Gamut alarm"""
+ SOFTPROOFING = 0x4000
+ """Do softproofing"""
+ BLACKPOINTCOMPENSATION = 0x2000
+ NOWHITEONWHITEFIXUP = 0x0004
+ """Don't fix scum dot"""
+ HIGHRESPRECALC = 0x0400
+ """Use more memory to give better accuracy"""
+ LOWRESPRECALC = 0x0800
+ """Use less memory to minimize resources"""
+ # this should be 8BITS_DEVICELINK, but that is not a valid name in Python:
+ USE_8BITS_DEVICELINK = 0x0008
+ """Create 8 bits devicelinks"""
+ GUESSDEVICECLASS = 0x0020
+ """Guess device class (for ``transform2devicelink``)"""
+ KEEP_SEQUENCE = 0x0080
+ """Keep profile sequence for devicelink creation"""
+ FORCE_CLUT = 0x0002
+ """Force CLUT optimization"""
+ CLUT_POST_LINEARIZATION = 0x0001
+ """create postlinearization tables if possible"""
+ CLUT_PRE_LINEARIZATION = 0x0010
+ """create prelinearization tables if possible"""
+ NONEGATIVES = 0x8000
+ """Prevent negative numbers in floating point transforms"""
+ COPY_ALPHA = 0x04000000
+ """Alpha channels are copied on ``cmsDoTransform()``"""
+ NODEFAULTRESOURCEDEF = 0x01000000
+
+ _GRIDPOINTS_1 = 1 << 16
+ _GRIDPOINTS_2 = 2 << 16
+ _GRIDPOINTS_4 = 4 << 16
+ _GRIDPOINTS_8 = 8 << 16
+ _GRIDPOINTS_16 = 16 << 16
+ _GRIDPOINTS_32 = 32 << 16
+ _GRIDPOINTS_64 = 64 << 16
+ _GRIDPOINTS_128 = 128 << 16
+
+ @staticmethod
+ def GRIDPOINTS(n: int) -> Flags:
+ """
+ Fine-tune control over number of gridpoints
+
+ :param n: :py:class:`int` in range ``0 <= n <= 255``
+ """
+ return Flags.NONE | ((n & 0xFF) << 16)
+
+
+_MAX_FLAG = reduce(operator.or_, Flags)
+
+
+_FLAGS = {
"MATRIXINPUT": 1,
"MATRIXOUTPUT": 2,
"MATRIXONLY": (1 | 2),
@@ -143,11 +228,6 @@ class Direction(IntEnum):
"GRIDPOINTS": lambda n: (n & 0xFF) << 16, # Gridpoints
}
-_MAX_FLAG = 0
-for flag in FLAGS.values():
- if isinstance(flag, int):
- _MAX_FLAG = _MAX_FLAG | flag
-
# --------------------------------------------------------------------.
# Experimental PIL-level API
@@ -219,7 +299,7 @@ def __init__(
intent=Intent.PERCEPTUAL,
proof=None,
proof_intent=Intent.ABSOLUTE_COLORIMETRIC,
- flags=0,
+ flags=Flags.NONE,
):
if proof is None:
self.transform = core.buildTransform(
@@ -304,7 +384,7 @@ def profileToProfile(
renderingIntent=Intent.PERCEPTUAL,
outputMode=None,
inPlace=False,
- flags=0,
+ flags=Flags.NONE,
):
"""
(pyCMS) Applies an ICC transformation to a given image, mapping from
@@ -421,7 +501,7 @@ def buildTransform(
inMode,
outMode,
renderingIntent=Intent.PERCEPTUAL,
- flags=0,
+ flags=Flags.NONE,
):
"""
(pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the
@@ -483,7 +563,7 @@ def buildTransform(
raise PyCMSError(msg)
if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG):
- msg = "flags must be an integer between 0 and %s" + _MAX_FLAG
+ msg = f"flags must be an integer between 0 and {_MAX_FLAG}"
raise PyCMSError(msg)
try:
@@ -506,7 +586,7 @@ def buildProofTransform(
outMode,
renderingIntent=Intent.PERCEPTUAL,
proofRenderingIntent=Intent.ABSOLUTE_COLORIMETRIC,
- flags=FLAGS["SOFTPROOFING"],
+ flags=Flags.SOFTPROOFING,
):
"""
(pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the
@@ -587,7 +667,7 @@ def buildProofTransform(
raise PyCMSError(msg)
if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG):
- msg = "flags must be an integer between 0 and %s" + _MAX_FLAG
+ msg = f"flags must be an integer between 0 and {_MAX_FLAG}"
raise PyCMSError(msg)
try:
@@ -1005,4 +1085,9 @@ def versions():
(pyCMS) Fetches versions.
"""
- return VERSION, core.littlecms_version, sys.version.split()[0], __version__
+ deprecate(
+ "PIL.ImageCms.versions()",
+ 12,
+ '(PIL.features.version("littlecms2"), sys.version, PIL.__version__)',
+ )
+ return _VERSION, core.littlecms_version, sys.version.split()[0], __version__
diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py
index bfad27c82d6..ad59b066779 100644
--- a/src/PIL/ImageColor.py
+++ b/src/PIL/ImageColor.py
@@ -19,10 +19,12 @@
from __future__ import annotations
import re
+from functools import lru_cache
from . import Image
+@lru_cache
def getrgb(color):
"""
Convert a color string to an RGB or RGBA tuple. If the string cannot be
@@ -121,6 +123,7 @@ def getrgb(color):
raise ValueError(msg)
+@lru_cache
def getcolor(color, mode):
"""
Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
index ae4e23db17b..0923979af8b 100644
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -32,7 +32,7 @@
import itertools
import struct
import sys
-from typing import NamedTuple
+from typing import Any, NamedTuple
from . import Image
from ._deprecate import deprecate
@@ -94,7 +94,7 @@ class _Tile(NamedTuple):
encoder_name: str
extents: tuple[int, int, int, int]
offset: int
- args: tuple | str | None
+ args: tuple[Any, ...] | str | None
#
diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py
index 021b40c0ef4..035b83c4d77 100644
--- a/src/PIL/ImageFilter.py
+++ b/src/PIL/ImageFilter.py
@@ -396,7 +396,7 @@ def __init__(self, size, table, channels=3, target_mode=None, **kwargs):
if hasattr(table, "shape"):
try:
import numpy
- except ImportError: # pragma: no cover
+ except ImportError:
pass
if numpy and isinstance(table, numpy.ndarray):
diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py
index 6db7cc4eccb..a63b73b33f5 100644
--- a/src/PIL/ImageFont.py
+++ b/src/PIL/ImageFont.py
@@ -34,7 +34,7 @@
from enum import IntEnum
from io import BytesIO
from pathlib import Path
-from typing import IO
+from typing import BinaryIO
from . import Image
from ._util import is_directory, is_path
@@ -53,7 +53,7 @@ class Layout(IntEnum):
except ImportError as ex:
from ._util import DeferredError
- core = DeferredError(ex)
+ core = DeferredError.new(ex)
def _string_length_check(text):
@@ -149,6 +149,8 @@ def getmask(self, text, mode="", *args, **kwargs):
:return: An internal PIL storage memory instance as defined by the
:py:mod:`PIL.Image.core` interface module.
"""
+ _string_length_check(text)
+ Image._decompression_bomb_check(self.font.getsize(text))
return self.font.getmask(text, mode)
def getbbox(self, text, *args, **kwargs):
@@ -191,7 +193,7 @@ class FreeTypeFont:
def __init__(
self,
- font: bytes | str | Path | IO | None = None,
+ font: bytes | str | Path | BinaryIO | None = None,
size: float = 10,
index: int = 0,
encoding: str = "",
@@ -582,22 +584,13 @@ def getmask2(
_string_length_check(text)
if start is None:
start = (0, 0)
- im = None
- size = None
def fill(width, height):
- nonlocal im, size
-
size = (width, height)
- if Image.MAX_IMAGE_PIXELS is not None:
- pixels = max(1, width) * max(1, height)
- if pixels > 2 * Image.MAX_IMAGE_PIXELS:
- return
-
- im = Image.core.fill("RGBA" if mode == "RGBA" else "L", size)
- return im
+ Image._decompression_bomb_check(size)
+ return Image.core.fill("RGBA" if mode == "RGBA" else "L", size)
- offset = self.font.render(
+ return self.font.render(
text,
fill,
mode,
@@ -610,8 +603,6 @@ def fill(width, height):
start[0],
start[1],
)
- Image._decompression_bomb_check(size)
- return im, offset
def font_variant(
self, font=None, size=None, index=None, encoding=None, layout_engine=None
diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py
index 7ca512e7568..b77f4bce567 100644
--- a/src/PIL/ImageMath.py
+++ b/src/PIL/ImageMath.py
@@ -234,6 +234,11 @@ def eval(expression, _dict={}, **kw):
# build execution namespace
args = ops.copy()
+ for k in list(_dict.keys()) + list(kw.keys()):
+ if "__" in k or hasattr(builtins, k):
+ msg = f"'{k}' not allowed"
+ raise ValueError(msg)
+
args.update(_dict)
args.update(kw)
for k, v in args.items():
diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py
index 1fdaa9140ff..6aa82dadd9c 100644
--- a/src/PIL/ImageTransform.py
+++ b/src/PIL/ImageTransform.py
@@ -14,17 +14,29 @@
#
from __future__ import annotations
+from typing import Sequence
+
from . import Image
class Transform(Image.ImageTransformHandler):
- def __init__(self, data):
+ """Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`."""
+
+ method: Image.Transform
+
+ def __init__(self, data: Sequence[int]) -> None:
self.data = data
- def getdata(self):
+ def getdata(self) -> tuple[Image.Transform, Sequence[int]]:
return self.method, self.data
- def transform(self, size, image, **options):
+ def transform(
+ self,
+ size: tuple[int, int],
+ image: Image.Image,
+ **options: dict[str, str | int | tuple[int, ...] | list[int]],
+ ) -> Image.Image:
+ """Perform the transform. Called from :py:meth:`.Image.transform`."""
# can be overridden
method, data = self.getdata()
return image.transform(size, method, data, **options)
@@ -42,7 +54,7 @@ class AffineTransform(Transform):
This function can be used to scale, translate, rotate, and shear the
original image.
- See :py:meth:`~PIL.Image.Image.transform`
+ See :py:meth:`.Image.transform`
:param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows
from an affine transform matrix.
@@ -51,6 +63,26 @@ class AffineTransform(Transform):
method = Image.Transform.AFFINE
+class PerspectiveTransform(Transform):
+ """
+ Define a perspective image transform.
+
+ This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel
+ (x, y) in the output image, the new value is taken from a position
+ ((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in
+ the input image, rounded to nearest pixel.
+
+ This function can be used to scale, translate, rotate, and shear the
+ original image.
+
+ See :py:meth:`.Image.transform`
+
+ :param matrix: An 8-tuple (a, b, c, d, e, f, g, h).
+ """
+
+ method = Image.Transform.PERSPECTIVE
+
+
class ExtentTransform(Transform):
"""
Define a transform to extract a subregion from an image.
@@ -64,7 +96,7 @@ class ExtentTransform(Transform):
rectangle in the current image. It is slightly slower than crop, but about
as fast as a corresponding resize operation.
- See :py:meth:`~PIL.Image.Image.transform`
+ See :py:meth:`.Image.transform`
:param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the
input image's coordinate system. See :ref:`coordinate-system`.
@@ -80,7 +112,7 @@ class QuadTransform(Transform):
Maps a quadrilateral (a region defined by four corners) from the image to a
rectangle of the given size.
- See :py:meth:`~PIL.Image.Image.transform`
+ See :py:meth:`.Image.transform`
:param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the
upper left, lower left, lower right, and upper right corner of the
@@ -95,7 +127,7 @@ class MeshTransform(Transform):
Define a mesh image transform. A mesh transform consists of one or more
individual quad transforms.
- See :py:meth:`~PIL.Image.Image.transform`
+ See :py:meth:`.Image.transform`
:param data: A list of (bbox, quad) tuples.
"""
diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py
index e7dc3e4e4d2..4096094348a 100644
--- a/src/PIL/IptcImagePlugin.py
+++ b/src/PIL/IptcImagePlugin.py
@@ -16,30 +16,48 @@
#
from __future__ import annotations
-import os
-import tempfile
+from io import BytesIO
+from typing import Sequence
from . import Image, ImageFile
-from ._binary import i8, o8
from ._binary import i16be as i16
from ._binary import i32be as i32
+from ._deprecate import deprecate
COMPRESSION = {1: "raw", 5: "jpeg"}
-PAD = o8(0) * 4
+
+def __getattr__(name: str) -> bytes:
+ if name == "PAD":
+ deprecate("IptcImagePlugin.PAD", 12)
+ return b"\0\0\0\0"
+ msg = f"module '{__name__}' has no attribute '{name}'"
+ raise AttributeError(msg)
#
# Helpers
-def i(c):
- return i32((PAD + c)[-4:])
+def _i(c: bytes) -> int:
+ return i32((b"\0\0\0\0" + c)[-4:])
+
+
+def _i8(c: int | bytes) -> int:
+ return c if isinstance(c, int) else c[0]
+
+def i(c: bytes) -> int:
+ """.. deprecated:: 10.2.0"""
+ deprecate("IptcImagePlugin.i", 12)
+ return _i(c)
-def dump(c):
+
+def dump(c: Sequence[int | bytes]) -> None:
+ """.. deprecated:: 10.2.0"""
+ deprecate("IptcImagePlugin.dump", 12)
for i in c:
- print("%02x" % i8(i), end=" ")
+ print("%02x" % _i8(i), end=" ")
print()
@@ -52,10 +70,10 @@ class IptcImageFile(ImageFile.ImageFile):
format = "IPTC"
format_description = "IPTC/NAA"
- def getint(self, key):
- return i(self.info[key])
+ def getint(self, key: tuple[int, int]) -> int:
+ return _i(self.info[key])
- def field(self):
+ def field(self) -> tuple[tuple[int, int] | None, int]:
#
# get a IPTC field header
s = self.fp.read(5)
@@ -77,13 +95,13 @@ def field(self):
elif size == 128:
size = 0
elif size > 128:
- size = i(self.fp.read(size - 128))
+ size = _i(self.fp.read(size - 128))
else:
size = i16(s, 3)
return tag, size
- def _open(self):
+ def _open(self) -> None:
# load descriptive fields
while True:
offset = self.fp.tell()
@@ -103,10 +121,10 @@ def _open(self):
self.info[tag] = tagdata
# mode
- layers = i8(self.info[(3, 60)][0])
- component = i8(self.info[(3, 60)][1])
+ layers = self.info[(3, 60)][0]
+ component = self.info[(3, 60)][1]
if (3, 65) in self.info:
- id = i8(self.info[(3, 65)][0]) - 1
+ id = self.info[(3, 65)][0] - 1
else:
id = 0
if layers == 1 and not component:
@@ -128,27 +146,22 @@ def _open(self):
# tile
if tag == (8, 10):
- self.tile = [
- ("iptc", (compression, offset), (0, 0, self.size[0], self.size[1]))
- ]
+ self.tile = [("iptc", (0, 0) + self.size, offset, compression)]
def load(self):
if len(self.tile) != 1 or self.tile[0][0] != "iptc":
return ImageFile.ImageFile.load(self)
- type, tile, box = self.tile[0]
-
- encoding, offset = tile
+ offset, compression = self.tile[0][2:]
self.fp.seek(offset)
# Copy image data to temporary file
- o_fd, outfile = tempfile.mkstemp(text=False)
- o = os.fdopen(o_fd)
- if encoding == "raw":
+ o = BytesIO()
+ if compression == "raw":
# To simplify access to the extracted file,
# prepend a PPM header
- o.write("P5\n%d %d\n255\n" % self.size)
+ o.write(b"P5\n%d %d\n255\n" % self.size)
while True:
type, size = self.field()
if type != (8, 10):
@@ -159,17 +172,10 @@ def load(self):
break
o.write(s)
size -= len(s)
- o.close()
- try:
- with Image.open(outfile) as _im:
- _im.load()
- self.im = _im.im
- finally:
- try:
- os.unlink(outfile)
- except OSError:
- pass
+ with Image.open(o) as _im:
+ _im.load()
+ self.im = _im.im
Image.register_open(IptcImageFile.format, IptcImageFile)
@@ -185,8 +191,6 @@ def getiptcinfo(im):
:returns: A dictionary containing IPTC information, or None if
no IPTC information block was found.
"""
- import io
-
from . import JpegImagePlugin, TiffImagePlugin
data = None
@@ -221,7 +225,7 @@ class FakeImage:
# parse the IPTC information chunk
im.info = {}
- im.fp = io.BytesIO(data)
+ im.fp = BytesIO(data)
try:
im._open()
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
index 59bade303f8..81b8749a332 100644
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -87,10 +87,12 @@ def APP(self, marker):
self.info["dpi"] = jfif_density
self.info["jfif_unit"] = jfif_unit
self.info["jfif_density"] = jfif_density
- elif marker == 0xFFE1 and s[:5] == b"Exif\0":
- if "exif" not in self.info:
- # extract EXIF information (incomplete)
- self.info["exif"] = s # FIXME: value will change
+ elif marker == 0xFFE1 and s[:6] == b"Exif\0\0":
+ # extract EXIF information
+ if "exif" in self.info:
+ self.info["exif"] += s[6:]
+ else:
+ self.info["exif"] = s
self._exif_offset = self.fp.tell() - n + 6
elif marker == 0xFFE2 and s[:5] == b"FPXR\0":
# extract FlashPix information (incomplete)
@@ -783,6 +785,7 @@ def validate_qtables(qtables):
progressive,
info.get("smooth", 0),
optimize,
+ info.get("keep_rgb", False),
info.get("streamtype", 0),
dpi[0],
dpi[1],
diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py
index d602a163349..0d1968b140a 100644
--- a/src/PIL/PcfFontFile.py
+++ b/src/PIL/PcfFontFile.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import io
+from typing import BinaryIO, Callable
from . import FontFile, Image
from ._binary import i8
@@ -41,7 +42,7 @@
PCF_GLYPH_NAMES = 1 << 7
PCF_BDF_ACCELERATORS = 1 << 8
-BYTES_PER_ROW = [
+BYTES_PER_ROW: list[Callable[[int], int]] = [
lambda bits: ((bits + 7) >> 3),
lambda bits: ((bits + 15) >> 3) & ~1,
lambda bits: ((bits + 31) >> 3) & ~3,
@@ -49,7 +50,7 @@
]
-def sz(s, o):
+def sz(s: bytes, o: int) -> bytes:
return s[o : s.index(b"\0", o)]
@@ -58,7 +59,7 @@ class PcfFontFile(FontFile.FontFile):
name = "name"
- def __init__(self, fp, charset_encoding="iso8859-1"):
+ def __init__(self, fp: BinaryIO, charset_encoding: str = "iso8859-1"):
self.charset_encoding = charset_encoding
magic = l32(fp.read(4))
@@ -104,7 +105,9 @@ def __init__(self, fp, charset_encoding="iso8859-1"):
bitmaps[ix],
)
- def _getformat(self, tag):
+ def _getformat(
+ self, tag: int
+ ) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]:
format, size, offset = self.toc[tag]
fp = self.fp
@@ -119,7 +122,7 @@ def _getformat(self, tag):
return fp, format, i16, i32
- def _load_properties(self):
+ def _load_properties(self) -> dict[bytes, bytes | int]:
#
# font properties
@@ -138,18 +141,16 @@ def _load_properties(self):
data = fp.read(i32(fp.read(4)))
for k, s, v in p:
- k = sz(data, k)
- if s:
- v = sz(data, v)
- properties[k] = v
+ property_value: bytes | int = sz(data, v) if s else v
+ properties[sz(data, k)] = property_value
return properties
- def _load_metrics(self):
+ def _load_metrics(self) -> list[tuple[int, int, int, int, int, int, int, int]]:
#
# font metrics
- metrics = []
+ metrics: list[tuple[int, int, int, int, int, int, int, int]] = []
fp, format, i16, i32 = self._getformat(PCF_METRICS)
@@ -182,7 +183,9 @@ def _load_metrics(self):
return metrics
- def _load_bitmaps(self, metrics):
+ def _load_bitmaps(
+ self, metrics: list[tuple[int, int, int, int, int, int, int, int]]
+ ) -> list[Image.Image]:
#
# bitmap data
@@ -222,7 +225,7 @@ def _load_bitmaps(self, metrics):
return bitmaps
- def _load_encoding(self):
+ def _load_encoding(self) -> list[int | None]:
fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS)
first_col, last_col = i16(fp.read(2)), i16(fp.read(2))
@@ -233,7 +236,7 @@ def _load_encoding(self):
nencoding = (last_col - first_col + 1) * (last_row - first_row + 1)
# map character code to bitmap index
- encoding = [None] * min(256, nencoding)
+ encoding: list[int | None] = [None] * min(256, nencoding)
encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)]
diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py
index 25dbfa5b0bc..9d37dcde099 100644
--- a/src/PIL/PpmImagePlugin.py
+++ b/src/PIL/PpmImagePlugin.py
@@ -15,6 +15,8 @@
#
from __future__ import annotations
+import math
+
from . import Image, ImageFile
from ._binary import i16be as i16
from ._binary import o8
@@ -35,6 +37,7 @@
b"P6": "RGB",
# extensions
b"P0CMYK": "CMYK",
+ b"Pf": "F",
# PIL extensions (for test purposes only)
b"PyP": "P",
b"PyRGBA": "RGBA",
@@ -43,7 +46,7 @@
def _accept(prefix):
- return prefix[0:1] == b"P" and prefix[1] in b"0123456y"
+ return prefix[0:1] == b"P" and prefix[1] in b"0123456fy"
##
@@ -97,6 +100,7 @@ def _open(self):
except KeyError:
msg = "not a PPM file"
raise SyntaxError(msg)
+ self._mode = mode
if magic_number in (b"P1", b"P4"):
self.custom_mimetype = "image/x-portable-bitmap"
@@ -105,40 +109,40 @@ def _open(self):
elif magic_number in (b"P3", b"P6"):
self.custom_mimetype = "image/x-portable-pixmap"
- maxval = None
+ self._size = int(self._read_token()), int(self._read_token())
+
decoder_name = "raw"
if magic_number in (b"P1", b"P2", b"P3"):
decoder_name = "ppm_plain"
- for ix in range(3):
- token = int(self._read_token())
- if ix == 0: # token is the x size
- xsize = token
- elif ix == 1: # token is the y size
- ysize = token
- if mode == "1":
- self._mode = "1"
- rawmode = "1;I"
- break
- else:
- self._mode = rawmode = mode
- elif ix == 2: # token is maxval
- maxval = token
- if not 0 < maxval < 65536:
- msg = "maxval must be greater than 0 and less than 65536"
- raise ValueError(msg)
- if maxval > 255 and mode == "L":
- self._mode = "I"
+ if mode == "1":
+ args = "1;I"
+ elif mode == "F":
+ scale = float(self._read_token())
+ if scale == 0.0 or not math.isfinite(scale):
+ msg = "scale must be finite and non-zero"
+ raise ValueError(msg)
+ self.info["scale"] = abs(scale)
+
+ rawmode = "F;32F" if scale < 0 else "F;32BF"
+ args = (rawmode, 0, -1)
+ else:
+ maxval = int(self._read_token())
+ if not 0 < maxval < 65536:
+ msg = "maxval must be greater than 0 and less than 65536"
+ raise ValueError(msg)
+ if maxval > 255 and mode == "L":
+ self._mode = "I"
- if decoder_name != "ppm_plain":
- # If maxval matches a bit depth, use the raw decoder directly
- if maxval == 65535 and mode == "L":
- rawmode = "I;16B"
- elif maxval != 255:
- decoder_name = "ppm"
+ rawmode = mode
+ if decoder_name != "ppm_plain":
+ # If maxval matches a bit depth, use the raw decoder directly
+ if maxval == 65535 and mode == "L":
+ rawmode = "I;16B"
+ elif maxval != 255:
+ decoder_name = "ppm"
- args = (rawmode, 0, 1) if decoder_name == "raw" else (rawmode, maxval)
- self._size = xsize, ysize
- self.tile = [(decoder_name, (0, 0, xsize, ysize), self.fp.tell(), args)]
+ args = rawmode if decoder_name == "raw" else (rawmode, maxval)
+ self.tile = [(decoder_name, (0, 0) + self.size, self.fp.tell(), args)]
#
@@ -315,6 +319,8 @@ def _save(im, fp, filename):
rawmode, head = "I;16B", b"P5"
elif im.mode in ("RGB", "RGBA"):
rawmode, head = "RGB", b"P6"
+ elif im.mode == "F":
+ rawmode, head = "F;32F", b"Pf"
else:
msg = f"cannot write mode {im.mode} as PPM"
raise OSError(msg)
@@ -326,7 +332,10 @@ def _save(im, fp, filename):
fp.write(b"255\n")
else:
fp.write(b"65535\n")
- ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))])
+ elif head == b"Pf":
+ fp.write(b"-1.0\n")
+ row_order = -1 if im.mode == "F" else 1
+ ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))])
#
@@ -339,6 +348,6 @@ def _save(im, fp, filename):
Image.register_decoder("ppm", PpmDecoder)
Image.register_decoder("ppm_plain", PpmPlainDecoder)
-Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"])
+Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"])
Image.register_mime(PpmImageFile.format, "image/x-portable-anymap")
diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py
index 23ff154f6cc..07bb712d83e 100644
--- a/src/PIL/PyAccess.py
+++ b/src/PIL/PyAccess.py
@@ -43,7 +43,7 @@
# anything in core.
from ._util import DeferredError
- FFI = ffi = DeferredError(ex)
+ FFI = ffi = DeferredError.new(ex)
logger = logging.getLogger(__name__)
diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py
index c9923487d5e..7470663b4a1 100644
--- a/src/PIL/TarIO.py
+++ b/src/PIL/TarIO.py
@@ -21,7 +21,7 @@
from . import ContainerIO
-class TarIO(ContainerIO.ContainerIO):
+class TarIO(ContainerIO.ContainerIO[bytes]):
"""A file object that provides read access to a given member of a TAR file."""
def __init__(self, tarfile: str, file: str) -> None:
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
index fc242ca64c2..e20d4d5ea81 100644
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -1704,25 +1704,27 @@ def _save(im, fp, filename):
colormap += [0] * (256 - colors)
ifd[COLORMAP] = colormap
# data orientation
- stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8)
- # aim for given strip size (64 KB by default) when using libtiff writer
- if libtiff:
- im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE)
- rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, im.size[1])
- # JPEG encoder expects multiple of 8 rows
- if compression == "jpeg":
- rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1])
- else:
- rows_per_strip = im.size[1]
- if rows_per_strip == 0:
- rows_per_strip = 1
- strip_byte_counts = 1 if stride == 0 else stride * rows_per_strip
- strips_per_image = (im.size[1] + rows_per_strip - 1) // rows_per_strip
- ifd[ROWSPERSTRIP] = rows_per_strip
+ w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH]
+ stride = len(bits) * ((w * bits[0] + 7) // 8)
+ if ROWSPERSTRIP not in ifd:
+ # aim for given strip size (64 KB by default) when using libtiff writer
+ if libtiff:
+ im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE)
+ rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h)
+ # JPEG encoder expects multiple of 8 rows
+ if compression == "jpeg":
+ rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h)
+ else:
+ rows_per_strip = h
+ if rows_per_strip == 0:
+ rows_per_strip = 1
+ ifd[ROWSPERSTRIP] = rows_per_strip
+ strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP]
+ strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP]
if strip_byte_counts >= 2**16:
ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG
ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + (
- stride * im.size[1] - strip_byte_counts * (strips_per_image - 1),
+ stride * h - strip_byte_counts * (strips_per_image - 1),
)
ifd[STRIPOFFSETS] = tuple(
range(0, strip_byte_counts * strips_per_image, strip_byte_counts)
diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py
index c60c9cec1b7..0a07e8d0e12 100644
--- a/src/PIL/_binary.py
+++ b/src/PIL/_binary.py
@@ -18,16 +18,16 @@
from struct import pack, unpack_from
-def i8(c):
- return c if c.__class__ is int else c[0]
+def i8(c: bytes) -> int:
+ return c[0]
-def o8(i):
+def o8(i: int) -> bytes:
return bytes((i & 255,))
# Input, le = little endian, be = big endian
-def i16le(c, o=0):
+def i16le(c: bytes, o: int = 0) -> int:
"""
Converts a 2-bytes (16 bits) string to an unsigned integer.
@@ -37,7 +37,7 @@ def i16le(c, o=0):
return unpack_from(" int:
"""
Converts a 2-bytes (16 bits) string to a signed integer.
@@ -47,7 +47,7 @@ def si16le(c, o=0):
return unpack_from(" int:
"""
Converts a 2-bytes (16 bits) string to a signed integer, big endian.
@@ -57,7 +57,7 @@ def si16be(c, o=0):
return unpack_from(">h", c, o)[0]
-def i32le(c, o=0):
+def i32le(c: bytes, o: int = 0) -> int:
"""
Converts a 4-bytes (32 bits) string to an unsigned integer.
@@ -67,7 +67,7 @@ def i32le(c, o=0):
return unpack_from(" int:
"""
Converts a 4-bytes (32 bits) string to a signed integer.
@@ -77,26 +77,26 @@ def si32le(c, o=0):
return unpack_from(" int:
return unpack_from(">H", c, o)[0]
-def i32be(c, o=0):
+def i32be(c: bytes, o: int = 0) -> int:
return unpack_from(">I", c, o)[0]
# Output, le = little endian, be = big endian
-def o16le(i):
+def o16le(i: int) -> bytes:
return pack(" bytes:
return pack(" bytes:
return pack(">H", i)
-def o32be(i):
+def o32be(i: int) -> bytes:
return pack(">I", i)
diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi
new file mode 100644
index 00000000000..b0235555dc5
--- /dev/null
+++ b/src/PIL/_imagingcms.pyi
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from typing import Any
+
+def __getattr__(name: str) -> Any: ...
diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi
new file mode 100644
index 00000000000..b0235555dc5
--- /dev/null
+++ b/src/PIL/_imagingft.pyi
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from typing import Any
+
+def __getattr__(name: str) -> Any: ...
diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py
new file mode 100644
index 00000000000..608b2b41fa8
--- /dev/null
+++ b/src/PIL/_typing.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+import sys
+
+if sys.version_info >= (3, 10):
+ from typing import TypeGuard
+else:
+ try:
+ from typing_extensions import TypeGuard
+ except ImportError:
+ from typing import Any
+
+ class TypeGuard: # type: ignore[no-redef]
+ def __class_getitem__(cls, item: Any) -> type[bool]:
+ return bool
+
+
+__all__ = ["TypeGuard"]
diff --git a/src/PIL/_util.py b/src/PIL/_util.py
index 4634d335bba..13f369cca1d 100644
--- a/src/PIL/_util.py
+++ b/src/PIL/_util.py
@@ -2,20 +2,31 @@
import os
from pathlib import Path
+from typing import Any, NoReturn
+from ._typing import TypeGuard
-def is_path(f):
+
+def is_path(f: Any) -> TypeGuard[bytes | str | Path]:
return isinstance(f, (bytes, str, Path))
-def is_directory(f):
+def is_directory(f: Any) -> TypeGuard[bytes | str | Path]:
"""Checks if an object is a string, and that it points to a directory."""
return is_path(f) and os.path.isdir(f)
class DeferredError:
- def __init__(self, ex):
+ def __init__(self, ex: BaseException):
self.ex = ex
- def __getattr__(self, elt):
+ def __getattr__(self, elt: str) -> NoReturn:
raise self.ex
+
+ @staticmethod
+ def new(ex: BaseException) -> Any:
+ """
+ Creates an object that raises the wrapped exception ``ex`` when used,
+ and casts it to :py:obj:`~typing.Any` type.
+ """
+ return DeferredError(ex)
diff --git a/src/PIL/_version.py b/src/PIL/_version.py
index 7d994caf4d7..0568943b52b 100644
--- a/src/PIL/_version.py
+++ b/src/PIL/_version.py
@@ -1,4 +1,4 @@
# Master version for Pillow
from __future__ import annotations
-__version__ = "10.2.0.dev0"
+__version__ = "10.3.0.dev0"
diff --git a/src/_imaging.c b/src/_imaging.c
index 2270c77fe7e..59f80a35415 100644
--- a/src/_imaging.c
+++ b/src/_imaging.c
@@ -2649,6 +2649,26 @@ _font_new(PyObject *self_, PyObject *args) {
self->glyphs[i].sy0 = S16(B16(glyphdata, 14));
self->glyphs[i].sx1 = S16(B16(glyphdata, 16));
self->glyphs[i].sy1 = S16(B16(glyphdata, 18));
+
+ // Do not allow glyphs to extend beyond bitmap image
+ // Helps prevent DOS by stopping cropped images being larger than the original
+ if (self->glyphs[i].sx0 < 0) {
+ self->glyphs[i].dx0 -= self->glyphs[i].sx0;
+ self->glyphs[i].sx0 = 0;
+ }
+ if (self->glyphs[i].sy0 < 0) {
+ self->glyphs[i].dy0 -= self->glyphs[i].sy0;
+ self->glyphs[i].sy0 = 0;
+ }
+ if (self->glyphs[i].sx1 > self->bitmap->xsize) {
+ self->glyphs[i].dx1 -= self->glyphs[i].sx1 - self->bitmap->xsize;
+ self->glyphs[i].sx1 = self->bitmap->xsize;
+ }
+ if (self->glyphs[i].sy1 > self->bitmap->ysize) {
+ self->glyphs[i].dy1 -= self->glyphs[i].sy1 - self->bitmap->ysize;
+ self->glyphs[i].sy1 = self->bitmap->ysize;
+ }
+
if (self->glyphs[i].dy0 < y0) {
y0 = self->glyphs[i].dy0;
}
@@ -2721,7 +2741,7 @@ _font_text_asBytes(PyObject *encoded_string, unsigned char **text) {
static PyObject *
_font_getmask(ImagingFontObject *self, PyObject *args) {
Imaging im;
- Imaging bitmap;
+ Imaging bitmap = NULL;
int x, b;
int i = 0;
int status;
@@ -2730,7 +2750,7 @@ _font_getmask(ImagingFontObject *self, PyObject *args) {
PyObject *encoded_string;
unsigned char *text;
- char *mode = "";
+ char *mode;
if (!PyArg_ParseTuple(args, "O|s:getmask", &encoded_string, &mode)) {
return NULL;
@@ -2753,10 +2773,13 @@ _font_getmask(ImagingFontObject *self, PyObject *args) {
b = self->baseline;
for (x = 0; text[i]; i++) {
glyph = &self->glyphs[text[i]];
- bitmap =
- ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1);
- if (!bitmap) {
- goto failed;
+ if (i == 0 || text[i] != text[i - 1]) {
+ ImagingDelete(bitmap);
+ bitmap =
+ ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1);
+ if (!bitmap) {
+ goto failed;
+ }
}
status = ImagingPaste(
im,
@@ -2766,17 +2789,18 @@ _font_getmask(ImagingFontObject *self, PyObject *args) {
glyph->dy0 + b,
glyph->dx1 + x,
glyph->dy1 + b);
- ImagingDelete(bitmap);
if (status < 0) {
goto failed;
}
x = x + glyph->dx;
b = b + glyph->dy;
}
+ ImagingDelete(bitmap);
free(text);
return PyImagingNew(im);
failed:
+ ImagingDelete(bitmap);
free(text);
ImagingDelete(im);
Py_RETURN_NONE;
diff --git a/src/_imagingft.c b/src/_imagingft.c
index 68c66ac2c60..6e24fcf95ed 100644
--- a/src/_imagingft.c
+++ b/src/_imagingft.c
@@ -880,7 +880,7 @@ font_render(FontObject *self, PyObject *args) {
image = PyObject_CallFunction(fill, "ii", width, height);
if (image == Py_None) {
PyMem_Del(glyph_info);
- return Py_BuildValue("ii", 0, 0);
+ return Py_BuildValue("N(ii)", image, 0, 0);
} else if (image == NULL) {
PyMem_Del(glyph_info);
return NULL;
@@ -894,7 +894,7 @@ font_render(FontObject *self, PyObject *args) {
y_offset -= stroke_width;
if (count == 0 || width == 0 || height == 0) {
PyMem_Del(glyph_info);
- return Py_BuildValue("ii", x_offset, y_offset);
+ return Py_BuildValue("N(ii)", image, x_offset, y_offset);
}
if (stroke_width) {
@@ -1130,18 +1130,12 @@ font_render(FontObject *self, PyObject *args) {
if (bitmap_converted_ready) {
FT_Bitmap_Done(library, &bitmap_converted);
}
- Py_DECREF(image);
FT_Stroker_Done(stroker);
PyMem_Del(glyph_info);
- return Py_BuildValue("ii", x_offset, y_offset);
+ return Py_BuildValue("N(ii)", image, x_offset, y_offset);
glyph_error:
- if (im->destroy) {
- im->destroy(im);
- }
- if (im->image) {
- free(im->image);
- }
+ Py_DECREF(image);
if (stroker != NULL) {
FT_Done_Glyph(glyph);
}
diff --git a/src/encode.c b/src/encode.c
index 4664ad0f32a..c7dd510150e 100644
--- a/src/encode.c
+++ b/src/encode.c
@@ -1042,6 +1042,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
Py_ssize_t progressive = 0;
Py_ssize_t smooth = 0;
Py_ssize_t optimize = 0;
+ int keep_rgb = 0;
Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */
Py_ssize_t xdpi = 0, ydpi = 0;
Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */
@@ -1059,13 +1060,14 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
if (!PyArg_ParseTuple(
args,
- "ss|nnnnnnnnnnOz#y#y#",
+ "ss|nnnnpnnnnnnOz#y#y#",
&mode,
&rawmode,
&quality,
&progressive,
&smooth,
&optimize,
+ &keep_rgb,
&streamtype,
&xdpi,
&ydpi,
@@ -1150,6 +1152,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
strncpy(((JPEGENCODERSTATE *)encoder->state.context)->rawmode, rawmode, 8);
+ ((JPEGENCODERSTATE *)encoder->state.context)->keep_rgb = keep_rgb;
((JPEGENCODERSTATE *)encoder->state.context)->quality = quality;
((JPEGENCODERSTATE *)encoder->state.context)->qtables = qarrays;
((JPEGENCODERSTATE *)encoder->state.context)->qtablesLen = qtablesLen;
diff --git a/src/libImaging/GifEncode.c b/src/libImaging/GifEncode.c
index f232454052a..e37301df765 100644
--- a/src/libImaging/GifEncode.c
+++ b/src/libImaging/GifEncode.c
@@ -105,7 +105,7 @@ static int glzwe(GIFENCODERSTATE *st, const UINT8 *in_ptr, UINT8 *out_ptr,
st->head = st->codes[st->probe] >> 20;
goto encode_loop;
} else {
- /* Reprobe decrement must be nonzero and relatively prime to table
+ /* Reprobe decrement must be non-zero and relatively prime to table
* size. So, any odd positive number for power-of-2 size. */
if ((st->probe -= ((st->tail << 2) | 1)) < 0) {
st->probe += TABLE_SIZE;
diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h
index 5cc74e69bf5..7cdba902281 100644
--- a/src/libImaging/Jpeg.h
+++ b/src/libImaging/Jpeg.h
@@ -74,6 +74,9 @@ typedef struct {
/* Optimize Huffman tables (slow) */
int optimize;
+ /* Disable automatic conversion of RGB images to YCbCr if non-zero */
+ int keep_rgb;
+
/* Stream type (0=full, 1=tables only, 2=image only) */
int streamtype;
diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c
index 9da830b186f..00f3d5f74db 100644
--- a/src/libImaging/JpegEncode.c
+++ b/src/libImaging/JpegEncode.c
@@ -137,6 +137,30 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
/* Compressor configuration */
jpeg_set_defaults(&context->cinfo);
+ /* Prevent RGB -> YCbCr conversion */
+ if (context->keep_rgb) {
+ switch (context->cinfo.in_color_space) {
+ case JCS_RGB:
+#ifdef JCS_EXTENSIONS
+ case JCS_EXT_RGBX:
+#endif
+ switch (context->subsampling) {
+ case -1: /* Default */
+ case 0: /* No subsampling */
+ break;
+ default:
+ /* Would subsample the green and blue
+ channels, which doesn't make sense */
+ state->errcode = IMAGING_CODEC_CONFIG;
+ return -1;
+ }
+ jpeg_set_colorspace(&context->cinfo, JCS_RGB);
+ break;
+ default:
+ break;
+ }
+ }
+
/* Use custom quantization tables */
if (context->qtables) {
int i;
diff --git a/tox.ini b/tox.ini
index 034d893721b..d89d017e45c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -35,5 +35,7 @@ skip_install = true
deps =
mypy==1.7.1
numpy
+extras =
+ typing
commands =
mypy src {posargs}
diff --git a/winbuild/build.rst b/winbuild/build.rst
index a8e4ebaa6cc..cd3b559e7f0 100644
--- a/winbuild/build.rst
+++ b/winbuild/build.rst
@@ -27,7 +27,7 @@ Download and install:
* `Ninja `_
(optional, use ``--nmake`` if not available; bundled in Visual Studio CMake component)
-* x86/x64: `Netwide Assembler (NASM) `_
+* x86/AMD64: `Netwide Assembler (NASM) `_
Any version of Visual Studio 2017 or newer should be supported,
including Visual Studio 2017 Community, or Build Tools for Visual Studio 2019.
@@ -42,7 +42,7 @@ Run ``build_prepare.py`` to configure the build::
usage: winbuild\build_prepare.py [-h] [-v] [-d PILLOW_BUILD]
[--depends PILLOW_DEPS]
- [--architecture {x86,x64,ARM64}] [--nmake]
+ [--architecture {x86,AMD64,ARM64}] [--nmake]
[--no-imagequant] [--no-fribidi]
Download and generate build scripts for Pillow dependencies.
@@ -55,7 +55,7 @@ Run ``build_prepare.py`` to configure the build::
--depends PILLOW_DEPS
directory used to store cached dependencies (default:
'winbuild\depends')
- --architecture {x86,x64,ARM64}
+ --architecture {x86,AMD64,ARM64}
build architecture (default: same as host Python)
--nmake build dependencies using NMake instead of Ninja
--no-imagequant skip GPL-licensed optional dependency libimagequant
diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py
index 8e3757ca89e..df33ea493e6 100644
--- a/winbuild/build_prepare.py
+++ b/winbuild/build_prepare.py
@@ -105,7 +105,7 @@ def cmd_msbuild(
ARCHITECTURES = {
"x86": {"vcvars_arch": "x86", "msbuild_arch": "Win32"},
- "x64": {"vcvars_arch": "x86_amd64", "msbuild_arch": "x64"},
+ "AMD64": {"vcvars_arch": "x86_amd64", "msbuild_arch": "x64"},
"ARM64": {"vcvars_arch": "x86_arm64", "msbuild_arch": "ARM64"},
}
@@ -174,23 +174,22 @@ def cmd_msbuild(
"filename": "libwebp-1.3.2.tar.gz",
"dir": "libwebp-1.3.2",
"license": "COPYING",
+ "patch": {
+ r"src\enc\picture_csp_enc.c": {
+ # link against libsharpyuv.lib
+ '#include "sharpyuv/sharpyuv.h"': '#include "sharpyuv/sharpyuv.h"\n#pragma comment(lib, "libsharpyuv.lib")', # noqa: E501
+ }
+ },
"build": [
- cmd_rmdir(r"output\release-static"), # clean
- cmd_nmake(
- "Makefile.vc",
- "all",
- [
- "CFG=release-static",
- "RTLIBCFG=dynamic",
- "OBJDIR=output",
- "ARCH={architecture}",
- "LIBWEBP_BASENAME=webp",
- ],
+ *cmds_cmake(
+ "webp webpdemux webpmux",
+ "-DBUILD_SHARED_LIBS:BOOL=OFF",
+ "-DWEBP_LINK_STATIC:BOOL=OFF",
),
cmd_mkdir(r"{inc_dir}\webp"),
cmd_copy(r"src\webp\*.h", r"{inc_dir}\webp"),
],
- "libs": [r"output\release-static\{architecture}\lib\*.lib"],
+ "libs": [r"libsharpyuv.lib", r"libwebp*.lib"],
},
"libtiff": {
"url": "https://download.osgeo.org/libtiff/tiff-4.6.0.tar.gz",
@@ -203,8 +202,8 @@ def cmd_msbuild(
"#ifdef LZMA_SUPPORT": '#ifdef LZMA_SUPPORT\n#pragma comment(lib, "liblzma.lib")', # noqa: E501
},
r"libtiff\tif_webp.c": {
- # link against webp.lib
- "#ifdef WEBP_SUPPORT": '#ifdef WEBP_SUPPORT\n#pragma comment(lib, "webp.lib")', # noqa: E501
+ # link against libwebp.lib
+ "#ifdef WEBP_SUPPORT": '#ifdef WEBP_SUPPORT\n#pragma comment(lib, "libwebp.lib")', # noqa: E501
},
r"test\CMakeLists.txt": {
"add_executable(test_write_read_tags ../placeholder.h)": "",
@@ -217,6 +216,7 @@ def cmd_msbuild(
*cmds_cmake(
"tiff",
"-DBUILD_SHARED_LIBS:BOOL=OFF",
+ "-DWebP_LIBRARY=libwebp",
'-DCMAKE_C_FLAGS="-nologo -DLZMA_API_STATIC"',
)
],
@@ -651,7 +651,7 @@ def build_dep_all() -> None:
(
"ARM64"
if platform.machine() == "ARM64"
- else ("x86" if struct.calcsize("P") == 4 else "x64")
+ else ("x86" if struct.calcsize("P") == 4 else "AMD64")
),
),
help="build architecture (default: same as host Python)",