Merge branch 'main' into progress

This commit is contained in:
Andrew Murray 2024-07-16 20:05:10 +10:00 committed by GitHub
commit 8b4b7ce7dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 2359 additions and 512 deletions

View File

@ -37,12 +37,18 @@ python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma python3 -m pip install pyroma
if [[ $(uname) != CYGWIN* ]]; then if [[ $(uname) != CYGWIN* ]]; then
python3 -m pip install numpy # TODO Update condition when NumPy supports free-threading
if [[ "$PYTHON_GIL" == "0" ]]; then
python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple
else
python3 -m pip install numpy
fi
# PyQt6 doesn't support PyPy3 # PyQt6 doesn't support PyPy3
if [[ $GHA_PYTHON_VERSION == 3.* ]]; then if [[ $GHA_PYTHON_VERSION == 3.* ]]; then
sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0
python3 -m pip install pyqt6 # TODO Update condition when pyqt6 supports free-threading
if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi
fi fi
# Pyroma uses non-isolated build and fails with old setuptools # Pyroma uses non-isolated build and fails with old setuptools

View File

@ -50,26 +50,24 @@ jobs:
"3.9", "3.9",
] ]
include: include:
- python-version: "3.11" - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" }
PYTHONOPTIMIZE: 1 - { python-version: "3.10", PYTHONOPTIMIZE: 2 }
REVERSE: "--reverse" # Free-threaded
- python-version: "3.10" - { os: "ubuntu-latest", python-version: "3.13-dev", disable-gil: true }
PYTHONOPTIMIZE: 2
# M1 only available for 3.10+ # M1 only available for 3.10+
- os: "macos-13" - { os: "macos-13", python-version: "3.9" }
python-version: "3.9"
exclude: exclude:
- os: "macos-14" - { os: "macos-14", python-version: "3.9" }
python-version: "3.9"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
if: "${{ !matrix.disable-gil }}"
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true allow-prereleases: true
@ -78,6 +76,18 @@ jobs:
".ci/*.sh" ".ci/*.sh"
"pyproject.toml" "pyproject.toml"
- name: Set up Python ${{ matrix.python-version }} (free-threaded)
uses: deadsnakes/action@v3.1.0
if: "${{ matrix.disable-gil }}"
with:
python-version: ${{ matrix.python-version }}
nogil: ${{ matrix.disable-gil }}
- name: Set PYTHON_GIL
if: "${{ matrix.disable-gil }}"
run: |
echo "PYTHON_GIL=0" >> $GITHUB_ENV
- name: Build system information - name: Build system information
run: python3 .github/workflows/system-info.py run: python3 .github/workflows/system-info.py

View File

@ -12,8 +12,14 @@ elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then
else else
yum install -y fribidi yum install -y fribidi
fi fi
if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then
# TODO Update condition when NumPy supports free-threading
if [ $(python3 -c "import sysconfig;print(sysconfig.get_config_var('Py_GIL_DISABLED'))") == "1" ]; then
python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple
else
python3 -m pip install numpy python3 -m pip install numpy
fi
fi fi
if [ ! -d "test-images-main" ]; then if [ ! -d "test-images-main" ]; then

View File

@ -41,11 +41,8 @@ jobs:
python-version: python-version:
- pp39 - pp39
- pp310 - pp310
- cp39 - cp3{9,10,11}
- cp310 - cp3{12,13}
- cp311
- cp312
- cp313
spec: spec:
- manylinux2014 - manylinux2014
- manylinux_2_28 - manylinux_2_28
@ -132,6 +129,7 @@ jobs:
env: env:
CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BUILD: ${{ matrix.build }} CIBW_BUILD: ${{ matrix.build }}
CIBW_FREE_THREADED_SUPPORT: True
CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_PRERELEASE_PYTHONS: True CIBW_PRERELEASE_PYTHONS: True
@ -204,6 +202,7 @@ jobs:
CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd"
CIBW_CACHE_PATH: "C:\\cibw" CIBW_CACHE_PATH: "C:\\cibw"
CIBW_FREE_THREADED_SUPPORT: True
CIBW_PRERELEASE_PYTHONS: True CIBW_PRERELEASE_PYTHONS: True
CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_SKIP: "*-win_arm64"
CIBW_TEST_COMMAND: 'docker run --rm CIBW_TEST_COMMAND: 'docker run --rm

View File

@ -2,6 +2,21 @@
Changelog (Pillow) Changelog (Pillow)
================== ==================
11.0.0 (unreleased)
-------------------
- Drop support for Python 3.8 #8183
[hugovk, radarhere]
- Add support for Python 3.13 #8181
[hugovk, radarhere]
- Fix incompatibility with NumPy 1.20 #8187
[neutrinoceros, radarhere]
- Remove PSFile, PyAccess and USE_CFFI_ACCESS #8182
[hugovk, radarhere]
10.4.0 (2024-07-01) 10.4.0 (2024-07-01)
------------------- -------------------

View File

@ -60,9 +60,7 @@ def convert_to_comparable(
return new_a, new_b return new_a, new_b
def assert_deep_equal( def assert_deep_equal(a: Any, b: Any, msg: str | None = None) -> None:
a: Sequence[Any], b: Sequence[Any], msg: str | None = None
) -> None:
try: try:
assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}" assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}"
except Exception: except Exception:

View File

@ -401,7 +401,7 @@ def test_palette_434(tmp_path: Path) -> None:
def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image: def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image:
out = str(tmp_path / "temp.gif") out = str(tmp_path / "temp.gif")
im.copy().save(out, **kwargs) im.copy().save(out, "GIF", **kwargs)
reloaded = Image.open(out) reloaded = Image.open(out)
return reloaded return reloaded

View File

@ -92,11 +92,22 @@ class TestFileLibTiff(LibTiffTestCase):
def test_g4_non_disk_file_object(self, tmp_path: Path) -> None: def test_g4_non_disk_file_object(self, tmp_path: Path) -> None:
"""Testing loading from non-disk non-BytesIO file object""" """Testing loading from non-disk non-BytesIO file object"""
test_file = "Tests/images/hopper_g4_500.tif" test_file = "Tests/images/hopper_g4_500.tif"
s = io.BytesIO()
with open(test_file, "rb") as f: with open(test_file, "rb") as f:
s.write(f.read()) data = f.read()
s.seek(0)
r = io.BufferedReader(s) class NonBytesIO(io.RawIOBase):
def read(self, size: int = -1) -> bytes:
nonlocal data
if size == -1:
size = len(data)
result = data[:size]
data = data[size:]
return result
def readable(self) -> bool:
return True
r = io.BufferedReader(NonBytesIO())
with Image.open(r) as im: with Image.open(r) as im:
assert im.size == (500, 500) assert im.size == (500, 500)
self._assert_noerr(tmp_path, im) self._assert_noerr(tmp_path, im)
@ -1048,7 +1059,11 @@ class TestFileLibTiff(LibTiffTestCase):
], ],
) )
def test_wrong_bits_per_sample( def test_wrong_bits_per_sample(
self, file_name: str, mode: str, size: tuple[int, int], tile self,
file_name: str,
mode: str,
size: tuple[int, int],
tile: list[tuple[str, tuple[int, int, int, int], int, tuple[Any, ...]]],
) -> None: ) -> None:
with Image.open("Tests/images/" + file_name) as im: with Image.open("Tests/images/" + file_name) as im:
assert im.mode == mode assert im.mode == mode
@ -1135,7 +1150,7 @@ class TestFileLibTiff(LibTiffTestCase):
arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"}
if argument: if argument:
arguments["strip_size"] = 2**18 arguments["strip_size"] = 2**18
im.save(out, **arguments) im.save(out, "TIFF", **arguments)
with Image.open(out) as im: with Image.open(out) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile) assert isinstance(im, TiffImagePlugin.TiffImageFile)

View File

@ -2,11 +2,11 @@ from __future__ import annotations
import warnings import warnings
from io import BytesIO from io import BytesIO
from typing import Any, cast from typing import Any
import pytest import pytest
from PIL import Image, MpoImagePlugin from PIL import Image, ImageFile, MpoImagePlugin
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
@ -20,11 +20,11 @@ test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"]
pytestmark = skip_unless_feature("jpg") pytestmark = skip_unless_feature("jpg")
def roundtrip(im: Image.Image, **options: Any) -> MpoImagePlugin.MpoImageFile: def roundtrip(im: Image.Image, **options: Any) -> ImageFile.ImageFile:
out = BytesIO() out = BytesIO()
im.save(out, "MPO", **options) im.save(out, "MPO", **options)
out.seek(0) out.seek(0)
return cast(MpoImagePlugin.MpoImageFile, Image.open(out)) return Image.open(out)
@pytest.mark.parametrize("test_file", test_files) @pytest.mark.parametrize("test_file", test_files)
@ -226,6 +226,12 @@ def test_eoferror() -> None:
im.seek(n_frames - 1) im.seek(n_frames - 1)
def test_adopt_jpeg() -> None:
with Image.open("Tests/images/hopper.jpg") as im:
with pytest.raises(ValueError):
MpoImagePlugin.MpoImageFile.adopt(im)
def test_ultra_hdr() -> None: def test_ultra_hdr() -> None:
with Image.open("Tests/images/ultrahdr.jpg") as im: with Image.open("Tests/images/ultrahdr.jpg") as im:
assert im.format == "JPEG" assert im.format == "JPEG"
@ -275,6 +281,8 @@ def test_save_all() -> None:
im_reloaded = roundtrip(im, save_all=True, append_images=[im2]) im_reloaded = roundtrip(im, save_all=True, append_images=[im2])
assert_image_equal(im, im_reloaded) assert_image_equal(im, im_reloaded)
assert isinstance(im_reloaded, MpoImagePlugin.MpoImageFile)
assert im_reloaded.mpinfo is not None
assert im_reloaded.mpinfo[45056] == b"0100" assert im_reloaded.mpinfo[45056] == b"0100"
im_reloaded.seek(1) im_reloaded.seek(1)

View File

@ -118,7 +118,7 @@ def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None:
im = hopper() im = hopper()
outfile = str(tmp_path / "temp.pdf") outfile = str(tmp_path / "temp.pdf")
im.save(outfile, **params) im.save(outfile, "PDF", **params)
with open(outfile, "rb") as fp: with open(outfile, "rb") as fp:
contents = fp.read() contents = fp.read()
@ -271,6 +271,7 @@ def test_pdf_append_fails_on_nonexistent_file() -> None:
def check_pdf_pages_consistency(pdf: PdfParser.PdfParser) -> None: def check_pdf_pages_consistency(pdf: PdfParser.PdfParser) -> None:
assert pdf.pages_ref is not None
pages_info = pdf.read_indirect(pdf.pages_ref) pages_info = pdf.read_indirect(pdf.pages_ref)
assert b"Parent" not in pages_info assert b"Parent" not in pages_info
assert b"Kids" in pages_info assert b"Kids" in pages_info

View File

@ -41,7 +41,7 @@ MAGIC = PngImagePlugin._MAGIC
def chunk(cid: bytes, *data: bytes) -> bytes: def chunk(cid: bytes, *data: bytes) -> bytes:
test_file = BytesIO() test_file = BytesIO()
PngImagePlugin.putchunk(*(test_file, cid) + data) PngImagePlugin.putchunk(test_file, cid, *data)
return test_file.getvalue() return test_file.getvalue()

View File

@ -78,6 +78,7 @@ class TestFileTiff:
def test_seek_after_close(self) -> None: def test_seek_after_close(self) -> None:
im = Image.open("Tests/images/multipage.tiff") im = Image.open("Tests/images/multipage.tiff")
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.close() im.close()
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -424,13 +425,13 @@ class TestFileTiff:
def test_load_float(self) -> None: def test_load_float(self) -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd = TiffImagePlugin.ImageFileDirectory_v2()
data = b"abcdabcd" data = b"abcdabcd"
ret = ifd.load_float(data, False) ret = getattr(ifd, "load_float")(data, False)
assert ret == (1.6777999408082104e22, 1.6777999408082104e22) assert ret == (1.6777999408082104e22, 1.6777999408082104e22)
def test_load_double(self) -> None: def test_load_double(self) -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd = TiffImagePlugin.ImageFileDirectory_v2()
data = b"abcdefghabcdefgh" data = b"abcdefghabcdefgh"
ret = ifd.load_double(data, False) ret = getattr(ifd, "load_double")(data, False)
assert ret == (8.540883223036124e194, 8.540883223036124e194) assert ret == (8.540883223036124e194, 8.540883223036124e194)
def test_ifd_tag_type(self) -> None: def test_ifd_tag_type(self) -> None:
@ -599,7 +600,7 @@ class TestFileTiff:
def test_with_underscores(self, tmp_path: Path) -> None: def test_with_underscores(self, tmp_path: Path) -> None:
kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36} kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36}
filename = str(tmp_path / "temp.tif") filename = str(tmp_path / "temp.tif")
hopper("RGB").save(filename, **kwargs) hopper("RGB").save(filename, "TIFF", **kwargs)
with Image.open(filename) as im: with Image.open(filename) as im:
# legacy interface # legacy interface
assert im.tag[X_RESOLUTION][0][0] == 72 assert im.tag[X_RESOLUTION][0][0] == 72
@ -624,14 +625,17 @@ class TestFileTiff:
def test_iptc(self, tmp_path: Path) -> None: def test_iptc(self, tmp_path: Path) -> None:
# Do not preserve IPTC_NAA_CHUNK by default if type is LONG # Do not preserve IPTC_NAA_CHUNK by default if type is LONG
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")
im = hopper() with Image.open("Tests/images/hopper.tif") as im:
ifd = TiffImagePlugin.ImageFileDirectory_v2() im.load()
ifd[33723] = 1 assert isinstance(im, TiffImagePlugin.TiffImageFile)
ifd.tagtype[33723] = 4 ifd = TiffImagePlugin.ImageFileDirectory_v2()
im.tag_v2 = ifd ifd[33723] = 1
im.save(outfile) ifd.tagtype[33723] = 4
im.tag_v2 = ifd
im.save(outfile)
with Image.open(outfile) as im: with Image.open(outfile) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert 33723 not in im.tag_v2 assert 33723 not in im.tag_v2
def test_rowsperstrip(self, tmp_path: Path) -> None: def test_rowsperstrip(self, tmp_path: Path) -> None:

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -96,7 +97,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
check(temp_file1) check(temp_file1)
# Tests appending using a generator # Tests appending using a generator
def im_generator(ims): def im_generator(
ims: list[Image.Image],
) -> Generator[Image.Image, None, None]:
yield from ims yield from ims
temp_file2 = str(tmp_path / "temp_generator.webp") temp_file2 = str(tmp_path / "temp_generator.webp")

View File

@ -372,8 +372,9 @@ class TestImage:
img = Image.alpha_composite(dst, src) img = Image.alpha_composite(dst, src)
# Assert # Assert
img_colors = sorted(img.getcolors()) img_colors = img.getcolors()
assert img_colors == expected_colors assert img_colors is not None
assert sorted(img_colors) == expected_colors
def test_alpha_inplace(self) -> None: def test_alpha_inplace(self) -> None:
src = Image.new("RGBA", (128, 128), "blue") src = Image.new("RGBA", (128, 128), "blue")
@ -670,7 +671,9 @@ class TestImage:
im_remapped = im.remap_palette([1, 0]) im_remapped = im.remap_palette([1, 0])
assert im_remapped.info["transparency"] == 1 assert im_remapped.info["transparency"] == 1
assert len(im_remapped.getpalette()) == 6 palette = im_remapped.getpalette()
assert palette is not None
assert len(palette) == 6
# Test unused transparency # Test unused transparency
im.info["transparency"] = 2 im.info["transparency"] = 2
@ -701,7 +704,7 @@ class TestImage:
else: else:
assert new_image.palette is None assert new_image.palette is None
_make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3)) _make_new(im, im_p, ImagePalette.ImagePalette("RGB"))
_make_new(im_p, im, None) _make_new(im_p, im, None)
_make_new(im, blank_p, ImagePalette.ImagePalette()) _make_new(im, blank_p, ImagePalette.ImagePalette())
_make_new(im, blank_pa, ImagePalette.ImagePalette()) _make_new(im, blank_pa, ImagePalette.ImagePalette())

View File

@ -27,7 +27,9 @@ class TestImagePutPixel:
for y in range(im1.size[1]): for y in range(im1.size[1]):
for x in range(im1.size[0]): for x in range(im1.size[0]):
pos = x, y pos = x, y
im2.putpixel(pos, im1.getpixel(pos)) value = im1.getpixel(pos)
assert value is not None
im2.putpixel(pos, value)
assert_image_equal(im1, im2) assert_image_equal(im1, im2)
@ -37,7 +39,9 @@ class TestImagePutPixel:
for y in range(im1.size[1]): for y in range(im1.size[1]):
for x in range(im1.size[0]): for x in range(im1.size[0]):
pos = x, y pos = x, y
im2.putpixel(pos, im1.getpixel(pos)) value = im1.getpixel(pos)
assert value is not None
im2.putpixel(pos, value)
assert not im2.readonly assert not im2.readonly
assert_image_equal(im1, im2) assert_image_equal(im1, im2)
@ -50,9 +54,9 @@ class TestImagePutPixel:
assert pix1 is not None assert pix1 is not None
assert pix2 is not None assert pix2 is not None
with pytest.raises(TypeError): with pytest.raises(TypeError):
pix1[0, "0"] pix1[0, "0"] # type: ignore[index]
with pytest.raises(TypeError): with pytest.raises(TypeError):
pix1["0", 0] pix1["0", 0] # type: ignore[index]
for y in range(im1.size[1]): for y in range(im1.size[1]):
for x in range(im1.size[0]): for x in range(im1.size[0]):
@ -71,7 +75,9 @@ class TestImagePutPixel:
for y in range(-1, -im1.size[1] - 1, -1): for y in range(-1, -im1.size[1] - 1, -1):
for x in range(-1, -im1.size[0] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1):
pos = x, y pos = x, y
im2.putpixel(pos, im1.getpixel(pos)) value = im1.getpixel(pos)
assert value is not None
im2.putpixel(pos, value)
assert_image_equal(im1, im2) assert_image_equal(im1, im2)
@ -81,7 +87,9 @@ class TestImagePutPixel:
for y in range(-1, -im1.size[1] - 1, -1): for y in range(-1, -im1.size[1] - 1, -1):
for x in range(-1, -im1.size[0] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1):
pos = x, y pos = x, y
im2.putpixel(pos, im1.getpixel(pos)) value = im1.getpixel(pos)
assert value is not None
im2.putpixel(pos, value)
assert not im2.readonly assert not im2.readonly
assert_image_equal(im1, im2) assert_image_equal(im1, im2)
@ -219,7 +227,7 @@ class TestImagePutPixelError:
im = hopper(mode) im = hopper(mode)
for v in self.INVALID_TYPES: for v in self.INVALID_TYPES:
with pytest.raises(TypeError, match="color must be int or tuple"): with pytest.raises(TypeError, match="color must be int or tuple"):
im.putpixel((0, 0), v) im.putpixel((0, 0), v) # type: ignore[arg-type]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("mode", "band_numbers", "match"), ("mode", "band_numbers", "match"),
@ -253,7 +261,7 @@ class TestImagePutPixelError:
with pytest.raises( with pytest.raises(
TypeError, match="color must be int or single-element tuple" TypeError, match="color must be int or single-element tuple"
): ):
im.putpixel((0, 0), v) im.putpixel((0, 0), v) # type: ignore[arg-type]
@pytest.mark.parametrize("mode", IMAGE_MODES1 + IMAGE_MODES2) @pytest.mark.parametrize("mode", IMAGE_MODES1 + IMAGE_MODES2)
def test_putpixel_overflow_error(self, mode: str) -> None: def test_putpixel_overflow_error(self, mode: str) -> None:

View File

@ -225,7 +225,7 @@ def test_l_macro_rounding(convert_mode: str) -> None:
assert px is not None assert px is not None
converted_color = px[0, 0] converted_color = px[0, 0]
if convert_mode == "LA": if convert_mode == "LA":
assert converted_color is not None assert isinstance(converted_color, tuple)
converted_color = converted_color[0] converted_color = converted_color[0]
assert converted_color == 1 assert converted_color == 1

View File

@ -54,17 +54,21 @@ def test_pack() -> None:
assert A is None assert A is None
A = im.getcolors(maxcolors=3) A = im.getcolors(maxcolors=3)
assert A is not None
A.sort() A.sort()
assert A == expected assert A == expected
A = im.getcolors(maxcolors=4) A = im.getcolors(maxcolors=4)
assert A is not None
A.sort() A.sort()
assert A == expected assert A == expected
A = im.getcolors(maxcolors=8) A = im.getcolors(maxcolors=8)
assert A is not None
A.sort() A.sort()
assert A == expected assert A == expected
A = im.getcolors(maxcolors=16) A = im.getcolors(maxcolors=16)
assert A is not None
A.sort() A.sort()
assert A == expected assert A == expected

View File

@ -31,7 +31,7 @@ def test_sanity() -> None:
def test_long_integers() -> None: def test_long_integers() -> None:
# see bug-200802-systemerror # see bug-200802-systemerror
def put(value: int) -> tuple[int, int, int, int]: def put(value: int) -> float | tuple[int, ...] | None:
im = Image.new("RGBA", (1, 1)) im = Image.new("RGBA", (1, 1))
im.putdata([value]) im.putdata([value])
return im.getpixel((0, 0)) return im.getpixel((0, 0))

View File

@ -31,7 +31,9 @@ def test_libimagequant_quantize() -> None:
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P" assert converted.mode == "P"
assert_image_similar(converted.convert("RGB"), image, 15) assert_image_similar(converted.convert("RGB"), image, 15)
assert len(converted.getcolors()) == 100 colors = converted.getcolors()
assert colors is not None
assert len(colors) == 100
def test_octree_quantize() -> None: def test_octree_quantize() -> None:
@ -39,7 +41,9 @@ def test_octree_quantize() -> None:
converted = image.quantize(100, Image.Quantize.FASTOCTREE) converted = image.quantize(100, Image.Quantize.FASTOCTREE)
assert converted.mode == "P" assert converted.mode == "P"
assert_image_similar(converted.convert("RGB"), image, 20) assert_image_similar(converted.convert("RGB"), image, 20)
assert len(converted.getcolors()) == 100 colors = converted.getcolors()
assert colors is not None
assert len(colors) == 100
def test_rgba_quantize() -> None: def test_rgba_quantize() -> None:
@ -158,4 +162,6 @@ def test_small_palette() -> None:
im = im.quantize(palette=p) im = im.quantize(palette=p)
# Assert # Assert
assert len(im.getcolors()) == 2 quantized_colors = im.getcolors()
assert quantized_colors is not None
assert len(quantized_colors) == 2

View File

@ -237,13 +237,13 @@ class TestImagingCoreResampleAccuracy:
class TestCoreResampleConsistency: class TestCoreResampleConsistency:
def make_case( def make_case(
self, mode: str, fill: tuple[int, int, int] | float self, mode: str, fill: tuple[int, int, int] | float
) -> tuple[Image.Image, tuple[int, ...]]: ) -> tuple[Image.Image, float | tuple[int, ...]]:
im = Image.new(mode, (512, 9), fill) im = Image.new(mode, (512, 9), fill)
px = im.load() px = im.load()
assert px is not None assert px is not None
return im.resize((9, 512), Image.Resampling.LANCZOS), px[0, 0] return im.resize((9, 512), Image.Resampling.LANCZOS), px[0, 0]
def run_case(self, case: tuple[Image.Image, int | tuple[int, ...]]) -> None: def run_case(self, case: tuple[Image.Image, float | tuple[int, ...]]) -> None:
channel, color = case channel, color = case
px = channel.load() px = channel.load()
assert px is not None assert px is not None
@ -256,6 +256,7 @@ class TestCoreResampleConsistency:
def test_8u(self) -> None: def test_8u(self) -> None:
im, color = self.make_case("RGB", (0, 64, 255)) im, color = self.make_case("RGB", (0, 64, 255))
r, g, b = im.split() r, g, b = im.split()
assert isinstance(color, tuple)
self.run_case((r, color[0])) self.run_case((r, color[0]))
self.run_case((g, color[1])) self.run_case((g, color[1]))
self.run_case((b, color[2])) self.run_case((b, color[2]))
@ -290,7 +291,11 @@ class TestCoreResampleAlphaCorrect:
px = i.load() px = i.load()
assert px is not None assert px is not None
for y in range(i.size[1]): for y in range(i.size[1]):
used_colors = {px[x, y][0] for x in range(i.size[0])} used_colors = set()
for x in range(i.size[0]):
value = px[x, y]
assert isinstance(value, tuple)
used_colors.add(value[0])
assert 256 == len(used_colors), ( assert 256 == len(used_colors), (
"All colors should be present in resized image. " "All colors should be present in resized image. "
f"Only {len(used_colors)} on line {y}." f"Only {len(used_colors)} on line {y}."
@ -332,12 +337,13 @@ class TestCoreResampleAlphaCorrect:
assert px is not None assert px is not None
for y in range(i.size[1]): for y in range(i.size[1]):
for x in range(i.size[0]): for x in range(i.size[0]):
if px[x, y][-1] != 0 and px[x, y][:-1] != clean_pixel: value = px[x, y]
assert isinstance(value, tuple)
if value[-1] != 0 and value[:-1] != clean_pixel:
message = ( message = (
f"pixel at ({x}, {y}) is different:\n" f"pixel at ({x}, {y}) is different:\n{value}\n{clean_pixel}"
f"{px[x, y]}\n{clean_pixel}"
) )
assert px[x, y][:3] == clean_pixel, message assert value[:3] == clean_pixel, message
def test_dirty_pixels_rgba(self) -> None: def test_dirty_pixels_rgba(self) -> None:
case = self.make_dirty_case("RGBA", (255, 255, 0, 128), (0, 0, 255, 0)) case = self.make_dirty_case("RGBA", (255, 255, 0, 128), (0, 0, 255, 0))

View File

@ -285,14 +285,14 @@ class TestReducingGapResize:
class TestImageResize: class TestImageResize:
def test_resize(self) -> None: def test_resize(self) -> None:
def resize(mode: str, size: tuple[int, int]) -> None: def resize(mode: str, size: tuple[int, int] | list[int]) -> None:
out = hopper(mode).resize(size) out = hopper(mode).resize(size)
assert out.mode == mode assert out.mode == mode
assert out.size == size assert out.size == tuple(size)
for mode in "1", "P", "L", "RGB", "I", "F": for mode in "1", "P", "L", "RGB", "I", "F":
resize(mode, (112, 103)) resize(mode, (112, 103))
resize(mode, (188, 214)) resize(mode, [188, 214])
# Test unknown resampling filter # Test unknown resampling filter
with hopper() as im: with hopper() as im:

View File

@ -192,8 +192,9 @@ class TestImageTransform:
im = op(im, (40, 10)) im = op(im, (40, 10))
colors = sorted(im.getcolors()) colors = im.getcolors()
assert colors == sorted( assert colors is not None
assert sorted(colors) == sorted(
( (
(20 * 10, opaque), (20 * 10, opaque),
(20 * 10, transparent), (20 * 10, transparent),

View File

@ -391,23 +391,25 @@ def test_overlay() -> None:
def test_logical() -> None: def test_logical() -> None:
def table( def table(
op: Callable[[Image.Image, Image.Image], Image.Image], a: int, b: int op: Callable[[Image.Image, Image.Image], Image.Image], a: int, b: int
) -> tuple[int, int, int, int]: ) -> list[float]:
out = [] out = []
for x in (a, b): for x in (a, b):
imx = Image.new("1", (1, 1), x) imx = Image.new("1", (1, 1), x)
for y in (a, b): for y in (a, b):
imy = Image.new("1", (1, 1), y) imy = Image.new("1", (1, 1), y)
out.append(op(imx, imy).getpixel((0, 0))) value = op(imx, imy).getpixel((0, 0))
return tuple(out) assert not isinstance(value, tuple) and value is not None
out.append(value)
return out
assert table(ImageChops.logical_and, 0, 1) == (0, 0, 0, 255) assert table(ImageChops.logical_and, 0, 1) == [0, 0, 0, 255]
assert table(ImageChops.logical_or, 0, 1) == (0, 255, 255, 255) assert table(ImageChops.logical_or, 0, 1) == [0, 255, 255, 255]
assert table(ImageChops.logical_xor, 0, 1) == (0, 255, 255, 0) assert table(ImageChops.logical_xor, 0, 1) == [0, 255, 255, 0]
assert table(ImageChops.logical_and, 0, 128) == (0, 0, 0, 255) assert table(ImageChops.logical_and, 0, 128) == [0, 0, 0, 255]
assert table(ImageChops.logical_or, 0, 128) == (0, 255, 255, 255) assert table(ImageChops.logical_or, 0, 128) == [0, 255, 255, 255]
assert table(ImageChops.logical_xor, 0, 128) == (0, 255, 255, 0) assert table(ImageChops.logical_xor, 0, 128) == [0, 255, 255, 0]
assert table(ImageChops.logical_and, 0, 255) == (0, 0, 0, 255) assert table(ImageChops.logical_and, 0, 255) == [0, 0, 0, 255]
assert table(ImageChops.logical_or, 0, 255) == (0, 255, 255, 255) assert table(ImageChops.logical_or, 0, 255) == [0, 255, 255, 255]
assert table(ImageChops.logical_xor, 0, 255) == (0, 255, 255, 0) assert table(ImageChops.logical_xor, 0, 255) == [0, 255, 255, 0]

View File

@ -691,7 +691,9 @@ def test_rgb_lab(mode: str) -> None:
im = Image.new("LAB", (1, 1), (255, 0, 0)) im = Image.new("LAB", (1, 1), (255, 0, 0))
converted_im = im.convert(mode) converted_im = im.convert(mode)
assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) value = converted_im.getpixel((0, 0))
assert isinstance(value, tuple)
assert value[:3] == (0, 255, 255)
def test_deprecation() -> None: def test_deprecation() -> None:

View File

@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import os.path import os.path
from collections.abc import Sequence from collections.abc import Sequence
from typing import Callable
import pytest import pytest
@ -1422,25 +1422,44 @@ def test_default_font_size() -> None:
im = Image.new("RGB", (220, 25)) im = Image.new("RGB", (220, 25))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError):
def check(func: Callable[[], None]) -> None:
if freetype_support:
func()
else:
with pytest.raises(ImportError):
func()
def draw_text() -> None:
draw.text((0, 0), text, font_size=16) draw.text((0, 0), text, font_size=16)
assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png")
with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): check(draw_text)
def draw_textlength() -> None:
assert draw.textlength(text, font_size=16) == 216 assert draw.textlength(text, font_size=16) == 216
with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): check(draw_textlength)
def draw_textbbox() -> None:
assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19)
check(draw_textbbox)
im = Image.new("RGB", (220, 25)) im = Image.new("RGB", (220, 25))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError):
def draw_multiline_text() -> None:
draw.multiline_text((0, 0), text, font_size=16) draw.multiline_text((0, 0), text, font_size=16)
assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png")
with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): check(draw_multiline_text)
def draw_multiline_textbbox() -> None:
assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19)
check(draw_multiline_textbbox)
@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("bbox", BBOX)
def test_same_color_outline(bbox: Coords) -> None: def test_same_color_outline(bbox: Coords) -> None:

View File

@ -90,6 +90,7 @@ class TestImageFile:
data = f.read() data = f.read()
with ImageFile.Parser() as p: with ImageFile.Parser() as p:
p.feed(data) p.feed(data)
assert p.image is not None
assert (48, 48) == p.image.size assert (48, 48) == p.image.size
@skip_unless_feature("webp") @skip_unless_feature("webp")
@ -103,6 +104,7 @@ class TestImageFile:
assert not p.image assert not p.image
p.feed(f.read()) p.feed(f.read())
assert p.image is not None
assert (128, 128) == p.image.size assert (128, 128) == p.image.size
@skip_unless_feature("zlib") @skip_unless_feature("zlib")
@ -125,7 +127,7 @@ class TestImageFile:
def test_raise_typeerror(self) -> None: def test_raise_typeerror(self) -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
parser = ImageFile.Parser() parser = ImageFile.Parser()
parser.feed(1) parser.feed(1) # type: ignore[arg-type]
def test_negative_stride(self) -> None: def test_negative_stride(self) -> None:
with open("Tests/images/raw_negative_stride.bin", "rb") as f: with open("Tests/images/raw_negative_stride.bin", "rb") as f:
@ -303,9 +305,9 @@ class TestPyDecoder(CodecsTest):
im.load() im.load()
def test_decode(self) -> None: def test_decode(self) -> None:
decoder = ImageFile.PyDecoder(None) decoder = ImageFile.PyDecoder("")
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
decoder.decode(None) decoder.decode(b"")
class TestPyEncoder(CodecsTest): class TestPyEncoder(CodecsTest):
@ -381,7 +383,7 @@ class TestPyEncoder(CodecsTest):
) )
def test_encode(self) -> None: def test_encode(self) -> None:
encoder = ImageFile.PyEncoder(None) encoder = ImageFile.PyEncoder("")
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
encoder.encode(0) encoder.encode(0)
@ -393,8 +395,9 @@ class TestPyEncoder(CodecsTest):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
encoder.encode_to_pyfd() encoder.encode_to_pyfd()
fh = BytesIO()
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
encoder.encode_to_file(None, None) encoder.encode_to_file(fh, 0)
def test_zero_height(self) -> None: def test_zero_height(self) -> None:
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):

View File

@ -60,6 +60,8 @@ class TestImageGrab:
def test_grabclipboard(self) -> None: def test_grabclipboard(self) -> None:
if sys.platform == "darwin": if sys.platform == "darwin":
subprocess.call(["screencapture", "-cx"]) subprocess.call(["screencapture", "-cx"])
ImageGrab.grabclipboard()
elif sys.platform == "win32": elif sys.platform == "win32":
p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE)
p.stdin.write( p.stdin.write(
@ -69,6 +71,8 @@ $bmp = New-Object Drawing.Bitmap 200, 200
[Windows.Forms.Clipboard]::SetImage($bmp)""" [Windows.Forms.Clipboard]::SetImage($bmp)"""
) )
p.communicate() p.communicate()
ImageGrab.grabclipboard()
else: else:
if not shutil.which("wl-paste") and not shutil.which("xclip"): if not shutil.which("wl-paste") and not shutil.which("xclip"):
with pytest.raises( with pytest.raises(
@ -77,9 +81,6 @@ $bmp = New-Object Drawing.Bitmap 200, 200
r" ImageGrab.grabclipboard\(\) on Linux", r" ImageGrab.grabclipboard\(\) on Linux",
): ):
ImageGrab.grabclipboard() ImageGrab.grabclipboard()
return
ImageGrab.grabclipboard()
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") @pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
def test_grabclipboard_file(self) -> None: def test_grabclipboard_file(self) -> None:

View File

@ -41,11 +41,15 @@ A = string_to_img(
def img_to_string(im: Image.Image) -> str: def img_to_string(im: Image.Image) -> str:
"""Turn a (small) binary image into a string representation""" """Turn a (small) binary image into a string representation"""
chars = ".1" chars = ".1"
width, height = im.size result = []
return "\n".join( for r in range(im.height):
"".join(chars[im.getpixel((c, r)) > 0] for c in range(width)) line = ""
for r in range(height) for c in range(im.width):
) value = im.getpixel((c, r))
assert not isinstance(value, tuple) and value is not None
line += chars[value > 0]
result.append(line)
return "\n".join(result)
def img_string_normalize(im: str) -> str: def img_string_normalize(im: str) -> str:

View File

@ -259,20 +259,26 @@ def test_colorize_2color() -> None:
left = (0, 1) left = (0, 1)
middle = (127, 1) middle = (127, 1)
right = (255, 1) right = (255, 1)
value = im_test.getpixel(left)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(left), value,
(255, 0, 0), (255, 0, 0),
threshold=1, threshold=1,
msg="black test pixel incorrect", msg="black test pixel incorrect",
) )
value = im_test.getpixel(middle)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(middle), value,
(127, 63, 0), (127, 63, 0),
threshold=1, threshold=1,
msg="mid test pixel incorrect", msg="mid test pixel incorrect",
) )
value = im_test.getpixel(right)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(right), value,
(0, 127, 0), (0, 127, 0),
threshold=1, threshold=1,
msg="white test pixel incorrect", msg="white test pixel incorrect",
@ -295,20 +301,26 @@ def test_colorize_2color_offset() -> None:
left = (25, 1) left = (25, 1)
middle = (75, 1) middle = (75, 1)
right = (125, 1) right = (125, 1)
value = im_test.getpixel(left)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(left), value,
(255, 0, 0), (255, 0, 0),
threshold=1, threshold=1,
msg="black test pixel incorrect", msg="black test pixel incorrect",
) )
value = im_test.getpixel(middle)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(middle), value,
(127, 63, 0), (127, 63, 0),
threshold=1, threshold=1,
msg="mid test pixel incorrect", msg="mid test pixel incorrect",
) )
value = im_test.getpixel(right)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(right), value,
(0, 127, 0), (0, 127, 0),
threshold=1, threshold=1,
msg="white test pixel incorrect", msg="white test pixel incorrect",
@ -339,29 +351,37 @@ def test_colorize_3color_offset() -> None:
middle = (100, 1) middle = (100, 1)
right_middle = (150, 1) right_middle = (150, 1)
right = (225, 1) right = (225, 1)
value = im_test.getpixel(left)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(left), value,
(255, 0, 0), (255, 0, 0),
threshold=1, threshold=1,
msg="black test pixel incorrect", msg="black test pixel incorrect",
) )
value = im_test.getpixel(left_middle)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(left_middle), value,
(127, 0, 127), (127, 0, 127),
threshold=1, threshold=1,
msg="low-mid test pixel incorrect", msg="low-mid test pixel incorrect",
) )
value = im_test.getpixel(middle)
assert isinstance(value, tuple)
assert_tuple_approx_equal(value, (0, 0, 255), threshold=1, msg="mid incorrect")
value = im_test.getpixel(right_middle)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(middle), (0, 0, 255), threshold=1, msg="mid incorrect" value,
)
assert_tuple_approx_equal(
im_test.getpixel(right_middle),
(0, 63, 127), (0, 63, 127),
threshold=1, threshold=1,
msg="high-mid test pixel incorrect", msg="high-mid test pixel incorrect",
) )
value = im_test.getpixel(right)
assert isinstance(value, tuple)
assert_tuple_approx_equal( assert_tuple_approx_equal(
im_test.getpixel(right), value,
(0, 127, 0), (0, 127, 0),
threshold=1, threshold=1,
msg="white test pixel incorrect", msg="white test pixel incorrect",
@ -444,6 +464,7 @@ def test_exif_transpose_xml_without_xmp() -> None:
del im.info["xmp"] del im.info["xmp"]
transposed_im = ImageOps.exif_transpose(im) transposed_im = ImageOps.exif_transpose(im)
assert transposed_im is not None
assert 0x0112 not in transposed_im.getexif() assert 0x0112 not in transposed_im.getexif()

View File

@ -16,8 +16,11 @@ def test_sanity() -> None:
def test_register() -> None: def test_register() -> None:
# Test registering a viewer that is not a class # Test registering a viewer that is an instance
ImageShow.register("not a class") class TestViewer(ImageShow.Viewer):
pass
ImageShow.register(TestViewer())
# Restore original state # Restore original state
ImageShow._viewers.pop() ImageShow._viewers.pop()

View File

@ -45,10 +45,12 @@ def test_kw() -> None:
# Test "file" # Test "file"
im = ImageTk._get_image_from_kw(kw) im = ImageTk._get_image_from_kw(kw)
assert im is not None
assert_image_equal(im, im1) assert_image_equal(im, im1)
# Test "data" # Test "data"
im = ImageTk._get_image_from_kw(kw) im = ImageTk._get_image_from_kw(kw)
assert im is not None
assert_image_equal(im, im2) assert_image_equal(im, im2)
# Test no relevant entry # Test no relevant entry
@ -107,3 +109,6 @@ def test_bitmapimage() -> None:
# reloaded = ImageTk.getimage(im_tk) # reloaded = ImageTk.getimage(im_tk)
# assert_image_equal(reloaded, im) # assert_image_equal(reloaded, im)
with pytest.raises(ValueError):
ImageTk.BitmapImage()

View File

@ -57,6 +57,9 @@ class TestImageWinDib:
# Assert # Assert
assert dib.size == (128, 128) assert dib.size == (128, 128)
with pytest.raises(ValueError):
ImageWin.Dib(mode)
def test_dib_paste(self) -> None: def test_dib_paste(self) -> None:
# Arrange # Arrange
im = hopper() im = hopper()

View File

@ -198,6 +198,15 @@ def test_putdata() -> None:
assert len(im.getdata()) == len(arr) assert len(im.getdata()) == len(arr)
def test_resize() -> None:
im = hopper()
size = (64, 64)
im_resized = im.resize(numpy.array(size))
assert im_resized.size == size
@pytest.mark.parametrize( @pytest.mark.parametrize(
"dtype", "dtype",
( (

View File

@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational
from .helper import hopper, skip_unless_feature from .helper import hopper, skip_unless_feature
def _test_equal(num, denom, target) -> None: def _test_equal(
num: float | Fraction | IFDRational,
denom: int,
target: float | Fraction | IFDRational,
) -> None:
t = IFDRational(num, denom) t = IFDRational(num, denom)
assert target == t assert target == t

View File

@ -75,7 +75,7 @@ These platforms have been reported to work at the versions mentioned.
| Operating system | | Tested Python | | Latest tested | | Tested | | Operating system | | Tested Python | | Latest tested | | Tested |
| | | versions | | Pillow version | | processors | | | | versions | | Pillow version | | processors |
+==================================+============================+==================+==============+ +==================================+============================+==================+==============+
| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.3.0 |arm | | macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.4.0 |arm |
+----------------------------------+----------------------------+------------------+--------------+ +----------------------------------+----------------------------+------------------+--------------+
| macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | | macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm |
| +----------------------------+------------------+ | | +----------------------------+------------------+ |

View File

@ -155,3 +155,11 @@ follow_imports = "silent"
warn_redundant_casts = true warn_redundant_casts = true
warn_unreachable = true warn_unreachable = true
warn_unused_ignores = true warn_unused_ignores = true
exclude = [
'^Tests/oss-fuzz/fuzz_font.py$',
'^Tests/oss-fuzz/fuzz_pillow.py$',
'^Tests/test_qt_image_qapplication.py$',
'^Tests/test_font_pcf_charsets.py$',
'^Tests/test_font_pcf.py$',
'^Tests/test_file_tar.py$',
]

View File

@ -313,6 +313,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
self._blp_lengths = struct.unpack("<16I", self._safe_read(16 * 4)) self._blp_lengths = struct.unpack("<16I", self._safe_read(16 * 4))
def _safe_read(self, length: int) -> bytes: def _safe_read(self, length: int) -> bytes:
assert self.fd is not None
return ImageFile._safe_read(self.fd, length) return ImageFile._safe_read(self.fd, length)
def _read_palette(self) -> list[tuple[int, int, int, int]]: def _read_palette(self) -> list[tuple[int, int, int, int]]:

View File

@ -25,7 +25,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import IO from typing import IO, Any
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -72,16 +72,20 @@ class BmpImageFile(ImageFile.ImageFile):
for k, v in COMPRESSIONS.items(): for k, v in COMPRESSIONS.items():
vars()[k] = v vars()[k] = v
def _bitmap(self, header=0, offset=0): def _bitmap(self, header: int = 0, offset: int = 0) -> None:
"""Read relevant info about the BMP""" """Read relevant info about the BMP"""
read, seek = self.fp.read, self.fp.seek read, seek = self.fp.read, self.fp.seek
if header: if header:
seek(header) seek(header)
# read bmp header size @offset 14 (this is part of the header size) # read bmp header size @offset 14 (this is part of the header size)
file_info = {"header_size": i32(read(4)), "direction": -1} file_info: dict[str, bool | int | tuple[int, ...]] = {
"header_size": i32(read(4)),
"direction": -1,
}
# -------------------- If requested, read header at a specific position # -------------------- If requested, read header at a specific position
# read the rest of the bmp header, without its size # read the rest of the bmp header, without its size
assert isinstance(file_info["header_size"], int)
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4) header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1 # ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
@ -92,7 +96,7 @@ class BmpImageFile(ImageFile.ImageFile):
file_info["height"] = i16(header_data, 2) file_info["height"] = i16(header_data, 2)
file_info["planes"] = i16(header_data, 4) file_info["planes"] = i16(header_data, 4)
file_info["bits"] = i16(header_data, 6) file_info["bits"] = i16(header_data, 6)
file_info["compression"] = self.RAW file_info["compression"] = self.COMPRESSIONS["RAW"]
file_info["palette_padding"] = 3 file_info["palette_padding"] = 3
# --------------------------------------------- Windows Bitmap v3 to v5 # --------------------------------------------- Windows Bitmap v3 to v5
@ -122,8 +126,9 @@ class BmpImageFile(ImageFile.ImageFile):
) )
file_info["colors"] = i32(header_data, 28) file_info["colors"] = i32(header_data, 28)
file_info["palette_padding"] = 4 file_info["palette_padding"] = 4
assert isinstance(file_info["pixels_per_meter"], tuple)
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"]) self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
if file_info["compression"] == self.BITFIELDS: if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
masks = ["r_mask", "g_mask", "b_mask"] masks = ["r_mask", "g_mask", "b_mask"]
if len(header_data) >= 48: if len(header_data) >= 48:
if len(header_data) >= 52: if len(header_data) >= 52:
@ -144,6 +149,10 @@ class BmpImageFile(ImageFile.ImageFile):
file_info["a_mask"] = 0x0 file_info["a_mask"] = 0x0
for mask in masks: for mask in masks:
file_info[mask] = i32(read(4)) file_info[mask] = i32(read(4))
assert isinstance(file_info["r_mask"], int)
assert isinstance(file_info["g_mask"], int)
assert isinstance(file_info["b_mask"], int)
assert isinstance(file_info["a_mask"], int)
file_info["rgb_mask"] = ( file_info["rgb_mask"] = (
file_info["r_mask"], file_info["r_mask"],
file_info["g_mask"], file_info["g_mask"],
@ -164,24 +173,26 @@ class BmpImageFile(ImageFile.ImageFile):
self._size = file_info["width"], file_info["height"] self._size = file_info["width"], file_info["height"]
# ------- If color count was not found in the header, compute from bits # ------- If color count was not found in the header, compute from bits
assert isinstance(file_info["bits"], int)
file_info["colors"] = ( file_info["colors"] = (
file_info["colors"] file_info["colors"]
if file_info.get("colors", 0) if file_info.get("colors", 0)
else (1 << file_info["bits"]) else (1 << file_info["bits"])
) )
assert isinstance(file_info["colors"], int)
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
offset += 4 * file_info["colors"] offset += 4 * file_info["colors"]
# ---------------------- Check bit depth for unusual unsupported values # ---------------------- Check bit depth for unusual unsupported values
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], (None, None)) self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
if self.mode is None: if not self.mode:
msg = f"Unsupported BMP pixel depth ({file_info['bits']})" msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
raise OSError(msg) raise OSError(msg)
# ---------------- Process BMP with Bitfields compression (not palette) # ---------------- Process BMP with Bitfields compression (not palette)
decoder_name = "raw" decoder_name = "raw"
if file_info["compression"] == self.BITFIELDS: if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
SUPPORTED = { SUPPORTED: dict[int, list[tuple[int, ...]]] = {
32: [ 32: [
(0xFF0000, 0xFF00, 0xFF, 0x0), (0xFF0000, 0xFF00, 0xFF, 0x0),
(0xFF000000, 0xFF0000, 0xFF00, 0x0), (0xFF000000, 0xFF0000, 0xFF00, 0x0),
@ -213,12 +224,14 @@ class BmpImageFile(ImageFile.ImageFile):
file_info["bits"] == 32 file_info["bits"] == 32
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]] and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
): ):
assert isinstance(file_info["rgba_mask"], tuple)
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])] raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
self._mode = "RGBA" if "A" in raw_mode else self.mode self._mode = "RGBA" if "A" in raw_mode else self.mode
elif ( elif (
file_info["bits"] in (24, 16) file_info["bits"] in (24, 16)
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]] and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
): ):
assert isinstance(file_info["rgb_mask"], tuple)
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])] raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
else: else:
msg = "Unsupported BMP bitfields layout" msg = "Unsupported BMP bitfields layout"
@ -226,10 +239,13 @@ class BmpImageFile(ImageFile.ImageFile):
else: else:
msg = "Unsupported BMP bitfields layout" msg = "Unsupported BMP bitfields layout"
raise OSError(msg) raise OSError(msg)
elif file_info["compression"] == self.RAW: elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset
raw_mode, self._mode = "BGRA", "RGBA" raw_mode, self._mode = "BGRA", "RGBA"
elif file_info["compression"] in (self.RLE8, self.RLE4): elif file_info["compression"] in (
self.COMPRESSIONS["RLE8"],
self.COMPRESSIONS["RLE4"],
):
decoder_name = "bmp_rle" decoder_name = "bmp_rle"
else: else:
msg = f"Unsupported BMP compression ({file_info['compression']})" msg = f"Unsupported BMP compression ({file_info['compression']})"
@ -242,6 +258,7 @@ class BmpImageFile(ImageFile.ImageFile):
msg = f"Unsupported BMP Palette size ({file_info['colors']})" msg = f"Unsupported BMP Palette size ({file_info['colors']})"
raise OSError(msg) raise OSError(msg)
else: else:
assert isinstance(file_info["palette_padding"], int)
padding = file_info["palette_padding"] padding = file_info["palette_padding"]
palette = read(padding * file_info["colors"]) palette = read(padding * file_info["colors"])
grayscale = True grayscale = True
@ -269,10 +286,11 @@ class BmpImageFile(ImageFile.ImageFile):
# ---------------------------- Finally set the tile data for the plugin # ---------------------------- Finally set the tile data for the plugin
self.info["compression"] = file_info["compression"] self.info["compression"] = file_info["compression"]
args = [raw_mode] args: list[Any] = [raw_mode]
if decoder_name == "bmp_rle": if decoder_name == "bmp_rle":
args.append(file_info["compression"] == self.RLE4) args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
else: else:
assert isinstance(file_info["width"], int)
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
args.append(file_info["direction"]) args.append(file_info["direction"])
self.tile = [ self.tile = [

View File

@ -65,7 +65,7 @@ def has_ghostscript() -> bool:
return gs_binary is not False return gs_binary is not False
def Ghostscript(tile, size, fp, scale=1, transparency=False): def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Image:
"""Render an image using Ghostscript""" """Render an image using Ghostscript"""
global gs_binary global gs_binary
if not has_ghostscript(): if not has_ghostscript():
@ -338,7 +338,7 @@ class EpsImageFile(ImageFile.ImageFile):
msg = "cannot determine EPS bounding box" msg = "cannot determine EPS bounding box"
raise OSError(msg) raise OSError(msg)
def _find_offset(self, fp): def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
s = fp.read(4) s = fp.read(4)
if s == b"%!PS": if s == b"%!PS":
@ -361,7 +361,9 @@ class EpsImageFile(ImageFile.ImageFile):
return length, offset return length, offset
def load(self, scale=1, transparency=False): def load(
self, scale: int = 1, transparency: bool = False
) -> Image.core.PixelAccess | None:
# Load EPS via Ghostscript # Load EPS via Ghostscript
if self.tile: if self.tile:
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)

View File

@ -45,7 +45,7 @@ class FliImageFile(ImageFile.ImageFile):
format_description = "Autodesk FLI/FLC Animation" format_description = "Autodesk FLI/FLC Animation"
_close_exclusive_fp_after_loading = False _close_exclusive_fp_after_loading = False
def _open(self): def _open(self) -> None:
# HEAD # HEAD
s = self.fp.read(128) s = self.fp.read(128)
if not (_accept(s) and s[20:22] == b"\x00\x00"): if not (_accept(s) and s[20:22] == b"\x00\x00"):
@ -83,7 +83,7 @@ class FliImageFile(ImageFile.ImageFile):
if i16(s, 4) == 0xF1FA: if i16(s, 4) == 0xF1FA:
# look for palette chunk # look for palette chunk
number_of_subchunks = i16(s, 6) number_of_subchunks = i16(s, 6)
chunk_size = None chunk_size: int | None = None
for _ in range(number_of_subchunks): for _ in range(number_of_subchunks):
if chunk_size is not None: if chunk_size is not None:
self.fp.seek(chunk_size - 6, os.SEEK_CUR) self.fp.seek(chunk_size - 6, os.SEEK_CUR)
@ -96,8 +96,9 @@ class FliImageFile(ImageFile.ImageFile):
if not chunk_size: if not chunk_size:
break break
palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] self.palette = ImagePalette.raw(
self.palette = ImagePalette.raw("RGB", b"".join(palette)) "RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
)
# set things up to decode first frame # set things up to decode first frame
self.__frame = -1 self.__frame = -1
@ -105,7 +106,7 @@ class FliImageFile(ImageFile.ImageFile):
self.__rewind = self.fp.tell() self.__rewind = self.fp.tell()
self.seek(0) self.seek(0)
def _palette(self, palette, shift): def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
# load palette # load palette
i = 0 i = 0

View File

@ -231,7 +231,7 @@ class FpxImageFile(ImageFile.ImageFile):
self._fp = self.fp self._fp = self.fp
self.fp = None self.fp = None
def load(self): def load(self) -> Image.core.PixelAccess | None:
if not self.fp: if not self.fp:
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])

View File

@ -88,7 +88,7 @@ class GbrImageFile(ImageFile.ImageFile):
# Data is an uncompressed block of w * h * bytes/pixel # Data is an uncompressed block of w * h * bytes/pixel
self._data_size = width * height * color_depth self._data_size = width * height * color_depth
def load(self): def load(self) -> Image.core.PixelAccess | None:
if not self.im: if not self.im:
self.im = Image.core.new(self.mode, self.size) self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self._data_size)) self.frombytes(self.fp.read(self._data_size))

View File

@ -34,11 +34,13 @@ MAGIC = b"icns"
HEADERSIZE = 8 HEADERSIZE = 8
def nextheader(fobj): def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]:
return struct.unpack(">4sI", fobj.read(HEADERSIZE)) return struct.unpack(">4sI", fobj.read(HEADERSIZE))
def read_32t(fobj, start_length, size): def read_32t(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
# The 128x128 icon seems to have an extra header for some reason. # The 128x128 icon seems to have an extra header for some reason.
(start, length) = start_length (start, length) = start_length
fobj.seek(start) fobj.seek(start)
@ -49,7 +51,9 @@ def read_32t(fobj, start_length, size):
return read_32(fobj, (start + 4, length - 4), size) return read_32(fobj, (start + 4, length - 4), size)
def read_32(fobj, start_length, size): def read_32(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
""" """
Read a 32bit RGB icon resource. Seems to be either uncompressed or Read a 32bit RGB icon resource. Seems to be either uncompressed or
an RLE packbits-like scheme. an RLE packbits-like scheme.
@ -72,14 +76,14 @@ def read_32(fobj, start_length, size):
byte = fobj.read(1) byte = fobj.read(1)
if not byte: if not byte:
break break
byte = byte[0] byte_int = byte[0]
if byte & 0x80: if byte_int & 0x80:
blocksize = byte - 125 blocksize = byte_int - 125
byte = fobj.read(1) byte = fobj.read(1)
for i in range(blocksize): for i in range(blocksize):
data.append(byte) data.append(byte)
else: else:
blocksize = byte + 1 blocksize = byte_int + 1
data.append(fobj.read(blocksize)) data.append(fobj.read(blocksize))
bytesleft -= blocksize bytesleft -= blocksize
if bytesleft <= 0: if bytesleft <= 0:
@ -92,7 +96,9 @@ def read_32(fobj, start_length, size):
return {"RGB": im} return {"RGB": im}
def read_mk(fobj, start_length, size): def read_mk(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
# Alpha masks seem to be uncompressed # Alpha masks seem to be uncompressed
start = start_length[0] start = start_length[0]
fobj.seek(start) fobj.seek(start)
@ -102,10 +108,14 @@ def read_mk(fobj, start_length, size):
return {"A": band} return {"A": band}
def read_png_or_jpeg2000(fobj, start_length, size): def read_png_or_jpeg2000(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
(start, length) = start_length (start, length) = start_length
fobj.seek(start) fobj.seek(start)
sig = fobj.read(12) sig = fobj.read(12)
im: Image.Image
if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a": if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a":
fobj.seek(start) fobj.seek(start)
im = PngImagePlugin.PngImageFile(fobj) im = PngImagePlugin.PngImageFile(fobj)
@ -164,12 +174,12 @@ class IcnsFile:
], ],
} }
def __init__(self, fobj): def __init__(self, fobj: IO[bytes]) -> None:
""" """
fobj is a file-like object as an icns resource fobj is a file-like object as an icns resource
""" """
# signature : (start, length) # signature : (start, length)
self.dct = dct = {} self.dct = {}
self.fobj = fobj self.fobj = fobj
sig, filesize = nextheader(fobj) sig, filesize = nextheader(fobj)
if not _accept(sig): if not _accept(sig):
@ -183,11 +193,11 @@ class IcnsFile:
raise SyntaxError(msg) raise SyntaxError(msg)
i += HEADERSIZE i += HEADERSIZE
blocksize -= HEADERSIZE blocksize -= HEADERSIZE
dct[sig] = (i, blocksize) self.dct[sig] = (i, blocksize)
fobj.seek(blocksize, io.SEEK_CUR) fobj.seek(blocksize, io.SEEK_CUR)
i += blocksize i += blocksize
def itersizes(self): def itersizes(self) -> list[tuple[int, int, int]]:
sizes = [] sizes = []
for size, fmts in self.SIZES.items(): for size, fmts in self.SIZES.items():
for fmt, reader in fmts: for fmt, reader in fmts:
@ -196,14 +206,14 @@ class IcnsFile:
break break
return sizes return sizes
def bestsize(self): def bestsize(self) -> tuple[int, int, int]:
sizes = self.itersizes() sizes = self.itersizes()
if not sizes: if not sizes:
msg = "No 32bit icon resources found" msg = "No 32bit icon resources found"
raise SyntaxError(msg) raise SyntaxError(msg)
return max(sizes) return max(sizes)
def dataforsize(self, size): def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]:
""" """
Get an icon resource as {channel: array}. Note that Get an icon resource as {channel: array}. Note that
the arrays are bottom-up like windows bitmaps and will likely the arrays are bottom-up like windows bitmaps and will likely
@ -216,18 +226,20 @@ class IcnsFile:
dct.update(reader(self.fobj, desc, size)) dct.update(reader(self.fobj, desc, size))
return dct return dct
def getimage(self, size=None): def getimage(
self, size: tuple[int, int] | tuple[int, int, int] | None = None
) -> Image.Image:
if size is None: if size is None:
size = self.bestsize() size = self.bestsize()
if len(size) == 2: elif len(size) == 2:
size = (size[0], size[1], 1) size = (size[0], size[1], 1)
channels = self.dataforsize(size) channels = self.dataforsize(size)
im = channels.get("RGBA", None) im = channels.get("RGBA")
if im: if im:
return im return im
im = channels.get("RGB").copy() im = channels["RGB"].copy()
try: try:
im.putalpha(channels["A"]) im.putalpha(channels["A"])
except KeyError: except KeyError:
@ -268,7 +280,7 @@ class IcnsImageFile(ImageFile.ImageFile):
return self._size return self._size
@size.setter @size.setter
def size(self, value): def size(self, value) -> None:
info_size = value info_size = value
if info_size not in self.info["sizes"] and len(info_size) == 2: if info_size not in self.info["sizes"] and len(info_size) == 2:
info_size = (info_size[0], info_size[1], 1) info_size = (info_size[0], info_size[1], 1)
@ -287,7 +299,7 @@ class IcnsImageFile(ImageFile.ImageFile):
raise ValueError(msg) raise ValueError(msg)
self._size = value self._size = value
def load(self): def load(self) -> Image.core.PixelAccess | None:
if len(self.size) == 3: if len(self.size) == 3:
self.best_size = self.size self.best_size = self.size
self.size = ( self.size = (

View File

@ -25,7 +25,7 @@ from __future__ import annotations
import warnings import warnings
from io import BytesIO from io import BytesIO
from math import ceil, log from math import ceil, log
from typing import IO from typing import IO, NamedTuple
from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -119,8 +119,22 @@ def _accept(prefix: bytes) -> bool:
return prefix[:4] == _MAGIC return prefix[:4] == _MAGIC
class IconHeader(NamedTuple):
width: int
height: int
nb_color: int
reserved: int
planes: int
bpp: int
size: int
offset: int
dim: tuple[int, int]
square: int
color_depth: int
class IcoFile: class IcoFile:
def __init__(self, buf): def __init__(self, buf: IO[bytes]) -> None:
""" """
Parse image from file-like object containing ico file data Parse image from file-like object containing ico file data
""" """
@ -141,55 +155,48 @@ class IcoFile:
for i in range(self.nb_items): for i in range(self.nb_items):
s = buf.read(16) s = buf.read(16)
icon_header = {
"width": s[0],
"height": s[1],
"nb_color": s[2], # No. of colors in image (0 if >=8bpp)
"reserved": s[3],
"planes": i16(s, 4),
"bpp": i16(s, 6),
"size": i32(s, 8),
"offset": i32(s, 12),
}
# See Wikipedia # See Wikipedia
for j in ("width", "height"): width = s[0] or 256
if not icon_header[j]: height = s[1] or 256
icon_header[j] = 256
# See Wikipedia notes about color depth. # No. of colors in image (0 if >=8bpp)
# We need this just to differ images with equal sizes nb_color = s[2]
icon_header["color_depth"] = ( bpp = i16(s, 6)
icon_header["bpp"] icon_header = IconHeader(
or ( width=width,
icon_header["nb_color"] != 0 height=height,
and ceil(log(icon_header["nb_color"], 2)) nb_color=nb_color,
) reserved=s[3],
or 256 planes=i16(s, 4),
bpp=i16(s, 6),
size=i32(s, 8),
offset=i32(s, 12),
dim=(width, height),
square=width * height,
# See Wikipedia notes about color depth.
# We need this just to differ images with equal sizes
color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256,
) )
icon_header["dim"] = (icon_header["width"], icon_header["height"])
icon_header["square"] = icon_header["width"] * icon_header["height"]
self.entry.append(icon_header) self.entry.append(icon_header)
self.entry = sorted(self.entry, key=lambda x: x["color_depth"]) self.entry = sorted(self.entry, key=lambda x: x.color_depth)
# ICO images are usually squares # ICO images are usually squares
self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True) self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
def sizes(self): def sizes(self) -> set[tuple[int, int]]:
""" """
Get a list of all available icon sizes and color depths. Get a set of all available icon sizes and color depths.
""" """
return {(h["width"], h["height"]) for h in self.entry} return {(h.width, h.height) for h in self.entry}
def getentryindex(self, size, bpp=False): def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int:
for i, h in enumerate(self.entry): for i, h in enumerate(self.entry):
if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): if size == h.dim and (bpp is False or bpp == h.color_depth):
return i return i
return 0 return 0
def getimage(self, size, bpp=False): def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image:
""" """
Get an image from the icon Get an image from the icon
""" """
@ -202,9 +209,9 @@ class IcoFile:
header = self.entry[idx] header = self.entry[idx]
self.buf.seek(header["offset"]) self.buf.seek(header.offset)
data = self.buf.read(8) data = self.buf.read(8)
self.buf.seek(header["offset"]) self.buf.seek(header.offset)
im: Image.Image im: Image.Image
if data[:8] == PngImagePlugin._MAGIC: if data[:8] == PngImagePlugin._MAGIC:
@ -222,8 +229,7 @@ class IcoFile:
im.tile[0] = d, (0, 0) + im.size, o, a im.tile[0] = d, (0, 0) + im.size, o, a
# figure out where AND mask image starts # figure out where AND mask image starts
bpp = header["bpp"] if header.bpp == 32:
if 32 == bpp:
# 32-bit color depth icon image allows semitransparent areas # 32-bit color depth icon image allows semitransparent areas
# PIL's DIB format ignores transparency bits, recover them. # PIL's DIB format ignores transparency bits, recover them.
# The DIB is packed in BGRX byte order where X is the alpha # The DIB is packed in BGRX byte order where X is the alpha
@ -253,7 +259,7 @@ class IcoFile:
# padded row size * height / bits per char # padded row size * height / bits per char
total_bytes = int((w * im.size[1]) / 8) total_bytes = int((w * im.size[1]) / 8)
and_mask_offset = header["offset"] + header["size"] - total_bytes and_mask_offset = header.offset + header.size - total_bytes
self.buf.seek(and_mask_offset) self.buf.seek(and_mask_offset)
mask_data = self.buf.read(total_bytes) mask_data = self.buf.read(total_bytes)
@ -307,7 +313,7 @@ class IcoImageFile(ImageFile.ImageFile):
def _open(self) -> None: def _open(self) -> None:
self.ico = IcoFile(self.fp) self.ico = IcoFile(self.fp)
self.info["sizes"] = self.ico.sizes() self.info["sizes"] = self.ico.sizes()
self.size = self.ico.entry[0]["dim"] self.size = self.ico.entry[0].dim
self.load() self.load()
@property @property
@ -321,7 +327,7 @@ class IcoImageFile(ImageFile.ImageFile):
raise ValueError(msg) raise ValueError(msg)
self._size = value self._size = value
def load(self): def load(self) -> Image.core.PixelAccess | None:
if self.im is not None and self.im.size == self.size: if self.im is not None and self.im.size == self.size:
# Already loaded # Already loaded
return Image.Image.load(self) return Image.Image.load(self)
@ -341,6 +347,7 @@ class IcoImageFile(ImageFile.ImageFile):
self.info["sizes"] = set(sizes) self.info["sizes"] = set(sizes)
self.size = im.size self.size = im.size
return None
def load_seek(self, pos: int) -> None: def load_seek(self, pos: int) -> None:
# Flag the ImageFile.Parser so that it # Flag the ImageFile.Parser so that it

View File

@ -63,7 +63,6 @@ from . import (
) )
from ._binary import i32le, o32be, o32le from ._binary import i32le, o32be, o32le
from ._deprecate import deprecate from ._deprecate import deprecate
from ._typing import StrOrBytesPath, TypeGuard
from ._util import DeferredError, is_path from ._util import DeferredError, is_path
ElementTree: ModuleType | None ElementTree: ModuleType | None
@ -220,6 +219,7 @@ if hasattr(core, "DEFAULT_STRATEGY"):
if TYPE_CHECKING: if TYPE_CHECKING:
from . import ImageFile, ImagePalette from . import ImageFile, ImagePalette
from ._typing import NumpyArray, StrOrBytesPath, TypeGuard
ID: list[str] = [] ID: list[str] = []
OPEN: dict[ OPEN: dict[
str, str,
@ -1395,7 +1395,7 @@ class Image:
def getcolors( def getcolors(
self, maxcolors: int = 256 self, maxcolors: int = 256
) -> list[tuple[int, int]] | list[tuple[int, float]] | None: ) -> list[tuple[int, tuple[int, ...]]] | list[tuple[int, float]] | None:
""" """
Returns a list of colors used in this image. Returns a list of colors used in this image.
@ -1412,7 +1412,7 @@ class Image:
self.load() self.load()
if self.mode in ("1", "L", "P"): if self.mode in ("1", "L", "P"):
h = self.im.histogram() h = self.im.histogram()
out = [(h[i], i) for i in range(256) if h[i]] out: list[tuple[int, float]] = [(h[i], i) for i in range(256) if h[i]]
if len(out) > maxcolors: if len(out) > maxcolors:
return None return None
return out return out
@ -1886,7 +1886,7 @@ class Image:
def point( def point(
self, self,
lut: Sequence[float] | Callable[[int], float] | ImagePointHandler, lut: Sequence[float] | NumpyArray | Callable[[int], float] | ImagePointHandler,
mode: str | None = None, mode: str | None = None,
) -> Image: ) -> Image:
""" """
@ -1996,7 +1996,7 @@ class Image:
def putdata( def putdata(
self, self,
data: Sequence[float] | Sequence[Sequence[int]], data: Sequence[float] | Sequence[Sequence[int]] | NumpyArray,
scale: float = 1.0, scale: float = 1.0,
offset: float = 0.0, offset: float = 0.0,
) -> None: ) -> None:
@ -2203,7 +2203,7 @@ class Image:
def resize( def resize(
self, self,
size: tuple[int, int], size: tuple[int, int] | list[int] | NumpyArray,
resample: int | None = None, resample: int | None = None,
box: tuple[float, float, float, float] | None = None, box: tuple[float, float, float, float] | None = None,
reducing_gap: float | None = None, reducing_gap: float | None = None,
@ -2211,7 +2211,7 @@ class Image:
""" """
Returns a resized copy of this image. Returns a resized copy of this image.
:param size: The requested size in pixels, as a 2-tuple: :param size: The requested size in pixels, as a tuple or array:
(width, height). (width, height).
:param resample: An optional resampling filter. This can be :param resample: An optional resampling filter. This can be
one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`,
@ -2276,6 +2276,7 @@ class Image:
if box is None: if box is None:
box = (0, 0) + self.size box = (0, 0) + self.size
size = tuple(size)
if self.size == size and box == (0, 0) + self.size: if self.size == size and box == (0, 0) + self.size:
return self.copy() return self.copy()
@ -3302,7 +3303,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1)
def fromqimage(im): def fromqimage(im) -> ImageFile.ImageFile:
"""Creates an image instance from a QImage image""" """Creates an image instance from a QImage image"""
from . import ImageQt from . import ImageQt
@ -3312,7 +3313,7 @@ def fromqimage(im):
return ImageQt.fromqimage(im) return ImageQt.fromqimage(im)
def fromqpixmap(im): def fromqpixmap(im) -> ImageFile.ImageFile:
"""Creates an image instance from a QPixmap image""" """Creates an image instance from a QPixmap image"""
from . import ImageQt from . import ImageQt
@ -3883,7 +3884,7 @@ class Exif(_ExifBase):
# returns a dict with any single item tuples/lists as individual values # returns a dict with any single item tuples/lists as individual values
return {k: self._fixup(v) for k, v in src_dict.items()} return {k: self._fixup(v) for k, v in src_dict.items()}
def _get_ifd_dict(self, offset, group=None): def _get_ifd_dict(self, offset: int, group=None):
try: try:
# an offset pointer to the location of the nested embedded IFD. # an offset pointer to the location of the nested embedded IFD.
# It should be a long, but may be corrupted. # It should be a long, but may be corrupted.
@ -3897,7 +3898,7 @@ class Exif(_ExifBase):
info.load(self.fp) info.load(self.fp)
return self._fixup_dict(info) return self._fixup_dict(info)
def _get_head(self): def _get_head(self) -> bytes:
version = b"\x2B" if self.bigtiff else b"\x2A" version = b"\x2B" if self.bigtiff else b"\x2A"
if self.endian == "<": if self.endian == "<":
head = b"II" + version + b"\x00" + o32le(8) head = b"II" + version + b"\x00" + o32le(8)
@ -4118,16 +4119,16 @@ class Exif(_ExifBase):
keys.update(self._info) keys.update(self._info)
return len(keys) return len(keys)
def __getitem__(self, tag): def __getitem__(self, tag: int):
if self._info is not None and tag not in self._data and tag in self._info: if self._info is not None and tag not in self._data and tag in self._info:
self._data[tag] = self._fixup(self._info[tag]) self._data[tag] = self._fixup(self._info[tag])
del self._info[tag] del self._info[tag]
return self._data[tag] return self._data[tag]
def __contains__(self, tag) -> bool: def __contains__(self, tag: object) -> bool:
return tag in self._data or (self._info is not None and tag in self._info) return tag in self._data or (self._info is not None and tag in self._info)
def __setitem__(self, tag, value) -> None: def __setitem__(self, tag: int, value) -> None:
if self._info is not None and tag in self._info: if self._info is not None and tag in self._info:
del self._info[tag] del self._info[tag]
self._data[tag] = value self._data[tag] = value

View File

@ -86,7 +86,7 @@ def raise_oserror(error: int) -> OSError:
raise _get_oserror(error, encoder=False) raise _get_oserror(error, encoder=False)
def _tilesort(t): def _tilesort(t) -> int:
# sort on offset # sort on offset
return t[2] return t[2]
@ -161,7 +161,7 @@ class ImageFile(Image.Image):
return Image.MIME.get(self.format.upper()) return Image.MIME.get(self.format.upper())
return None return None
def __setstate__(self, state): def __setstate__(self, state) -> None:
self.tile = [] self.tile = []
super().__setstate__(state) super().__setstate__(state)
@ -333,14 +333,14 @@ class ImageFile(Image.Image):
# def load_read(self, read_bytes: int) -> bytes: # def load_read(self, read_bytes: int) -> bytes:
# pass # pass
def _seek_check(self, frame): def _seek_check(self, frame: int) -> bool:
if ( if (
frame < self._min_frame frame < self._min_frame
# Only check upper limit on frames if additional seek operations # Only check upper limit on frames if additional seek operations
# are not required to do so # are not required to do so
or ( or (
not (hasattr(self, "_n_frames") and self._n_frames is None) not (hasattr(self, "_n_frames") and self._n_frames is None)
and frame >= self.n_frames + self._min_frame and frame >= getattr(self, "n_frames") + self._min_frame
) )
): ):
msg = "attempt to seek outside sequence" msg = "attempt to seek outside sequence"
@ -370,7 +370,7 @@ class StubImageFile(ImageFile):
msg = "StubImageFile subclass must implement _open" msg = "StubImageFile subclass must implement _open"
raise NotImplementedError(msg) raise NotImplementedError(msg)
def load(self): def load(self) -> Image.core.PixelAccess | None:
loader = self._load() loader = self._load()
if loader is None: if loader is None:
msg = f"cannot find loader for this {self.format} file" msg = f"cannot find loader for this {self.format} file"
@ -378,7 +378,7 @@ class StubImageFile(ImageFile):
image = loader.load(self) image = loader.load(self)
assert image is not None assert image is not None
# become the other object (!) # become the other object (!)
self.__class__ = image.__class__ self.__class__ = image.__class__ # type: ignore[assignment]
self.__dict__ = image.__dict__ self.__dict__ = image.__dict__
return image.load() return image.load()
@ -396,8 +396,8 @@ class Parser:
incremental = None incremental = None
image: Image.Image | None = None image: Image.Image | None = None
data = None data: bytes | None = None
decoder = None decoder: Image.core.ImagingDecoder | PyDecoder | None = None
offset = 0 offset = 0
finished = 0 finished = 0
@ -409,7 +409,7 @@ class Parser:
""" """
assert self.data is None, "cannot reuse parsers" assert self.data is None, "cannot reuse parsers"
def feed(self, data): def feed(self, data: bytes) -> None:
""" """
(Consumer) Feed data to the parser. (Consumer) Feed data to the parser.
@ -485,13 +485,13 @@ class Parser:
self.image = im self.image = im
def __enter__(self): def __enter__(self) -> Parser:
return self return self
def __exit__(self, *args: object) -> None: def __exit__(self, *args: object) -> None:
self.close() self.close()
def close(self): def close(self) -> Image.Image:
""" """
(Consumer) Close the stream. (Consumer) Close the stream.
@ -525,7 +525,7 @@ class Parser:
# -------------------------------------------------------------------- # --------------------------------------------------------------------
def _save(im, fp, tile, bufsize=0) -> None: def _save(im, fp, tile, bufsize: int = 0) -> None:
"""Helper to save image based on tile list """Helper to save image based on tile list
:param im: Image object. :param im: Image object.
@ -553,7 +553,9 @@ def _save(im, fp, tile, bufsize=0) -> None:
fp.flush() fp.flush()
def _encode_tile(im, fp, tile: list[_Tile], bufsize, fh, exc=None): def _encode_tile(
im, fp: IO[bytes], tile: list[_Tile], bufsize: int, fh, exc=None
) -> None:
for encoder_name, extents, offset, args in tile: for encoder_name, extents, offset, args in tile:
if offset > 0: if offset > 0:
fp.seek(offset) fp.seek(offset)
@ -580,7 +582,7 @@ def _encode_tile(im, fp, tile: list[_Tile], bufsize, fh, exc=None):
encoder.cleanup() encoder.cleanup()
def _safe_read(fp, size): def _safe_read(fp: IO[bytes], size: int) -> bytes:
""" """
Reads large blocks in a safe way. Unlike fp.read(n), this function Reads large blocks in a safe way. Unlike fp.read(n), this function
doesn't trust the user. If the requested size is larger than doesn't trust the user. If the requested size is larger than
@ -601,18 +603,18 @@ def _safe_read(fp, size):
msg = "Truncated File Read" msg = "Truncated File Read"
raise OSError(msg) raise OSError(msg)
return data return data
data = [] blocks: list[bytes] = []
remaining_size = size remaining_size = size
while remaining_size > 0: while remaining_size > 0:
block = fp.read(min(remaining_size, SAFEBLOCK)) block = fp.read(min(remaining_size, SAFEBLOCK))
if not block: if not block:
break break
data.append(block) blocks.append(block)
remaining_size -= len(block) remaining_size -= len(block)
if sum(len(d) for d in data) < size: if sum(len(block) for block in blocks) < size:
msg = "Truncated File Read" msg = "Truncated File Read"
raise OSError(msg) raise OSError(msg)
return b"".join(data) return b"".join(blocks)
class PyCodecState: class PyCodecState:
@ -629,18 +631,18 @@ class PyCodecState:
class PyCodec: class PyCodec:
fd: IO[bytes] | None fd: IO[bytes] | None
def __init__(self, mode, *args): def __init__(self, mode: str, *args: Any) -> None:
self.im = None self.im: Image.core.ImagingCore | None = None
self.state = PyCodecState() self.state = PyCodecState()
self.fd = None self.fd = None
self.mode = mode self.mode = mode
self.init(args) self.init(args)
def init(self, args): def init(self, args: tuple[Any, ...]) -> None:
""" """
Override to perform codec specific initialization Override to perform codec specific initialization
:param args: Array of args items from the tile entry :param args: Tuple of arg items from the tile entry
:returns: None :returns: None
""" """
self.args = args self.args = args
@ -653,7 +655,7 @@ class PyCodec:
""" """
pass pass
def setfd(self, fd): def setfd(self, fd: IO[bytes]) -> None:
""" """
Called from ImageFile to set the Python file-like object Called from ImageFile to set the Python file-like object
@ -662,7 +664,7 @@ class PyCodec:
""" """
self.fd = fd self.fd = fd
def setimage(self, im, extents: tuple[int, int, int, int] | None = None) -> None: def setimage(self, im, extents=None):
""" """
Called from ImageFile to set the core output image for the codec Called from ImageFile to set the core output image for the codec
@ -793,7 +795,7 @@ class PyEncoder(PyCodec):
self.fd.write(data) self.fd.write(data)
return bytes_consumed, errcode return bytes_consumed, errcode
def encode_to_file(self, fh, bufsize): def encode_to_file(self, fh: IO[bytes], bufsize: int) -> int:
""" """
:param fh: File handle. :param fh: File handle.
:param bufsize: Buffer size. :param bufsize: Buffer size.

View File

@ -19,11 +19,14 @@ from __future__ import annotations
import sys import sys
from io import BytesIO from io import BytesIO
from typing import Callable from typing import TYPE_CHECKING, Callable
from . import Image from . import Image
from ._util import is_path from ._util import is_path
if TYPE_CHECKING:
from . import ImageFile
qt_version: str | None qt_version: str | None
qt_versions = [ qt_versions = [
["6", "PyQt6"], ["6", "PyQt6"],
@ -90,11 +93,11 @@ def fromqimage(im):
return Image.open(b) return Image.open(b)
def fromqpixmap(im): def fromqpixmap(im) -> ImageFile.ImageFile:
return fromqimage(im) return fromqimage(im)
def align8to32(bytes, width, mode): def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
""" """
converts each scanline of data from 8 bit to 32 bit aligned converts each scanline of data from 8 bit to 32 bit aligned
""" """
@ -172,7 +175,7 @@ def _toqclass_helper(im):
if qt_is_installed: if qt_is_installed:
class ImageQt(QImage): class ImageQt(QImage):
def __init__(self, im): def __init__(self, im) -> None:
""" """
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
class. class.

View File

@ -33,7 +33,7 @@ class Iterator:
:param im: An image object. :param im: An image object.
""" """
def __init__(self, im: Image.Image): def __init__(self, im: Image.Image) -> None:
if not hasattr(im, "seek"): if not hasattr(im, "seek"):
msg = "im must have seek method" msg = "im must have seek method"
raise AttributeError(msg) raise AttributeError(msg)

View File

@ -26,7 +26,7 @@ from . import Image
_viewers = [] _viewers = []
def register(viewer, order: int = 1) -> None: def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None:
""" """
The :py:func:`register` function is used to register additional viewers:: The :py:func:`register` function is used to register additional viewers::
@ -40,11 +40,8 @@ def register(viewer, order: int = 1) -> None:
Zero or a negative integer to prepend this viewer to the list, Zero or a negative integer to prepend this viewer to the list,
a positive integer to append it. a positive integer to append it.
""" """
try: if isinstance(viewer, type) and issubclass(viewer, Viewer):
if issubclass(viewer, Viewer): viewer = viewer()
viewer = viewer()
except TypeError:
pass # raised if viewer wasn't a class
if order > 0: if order > 0:
_viewers.append(viewer) _viewers.append(viewer)
else: else:

View File

@ -28,7 +28,7 @@ from __future__ import annotations
import tkinter import tkinter
from io import BytesIO from io import BytesIO
from typing import Any from typing import TYPE_CHECKING, Any, cast
from . import Image, ImageFile from . import Image, ImageFile
@ -61,7 +61,9 @@ def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None:
return Image.open(source) return Image.open(source)
def _pyimagingtkcall(command, photo, id): def _pyimagingtkcall(
command: str, photo: PhotoImage | tkinter.PhotoImage, id: int
) -> None:
tk = photo.tk tk = photo.tk
try: try:
tk.call(command, photo, id) tk.call(command, photo, id)
@ -215,11 +217,14 @@ class BitmapImage:
:param image: A PIL image. :param image: A PIL image.
""" """
def __init__(self, image=None, **kw): def __init__(self, image: Image.Image | None = None, **kw: Any) -> None:
# Tk compatibility: file or data # Tk compatibility: file or data
if image is None: if image is None:
image = _get_image_from_kw(kw) image = _get_image_from_kw(kw)
if image is None:
msg = "Image is required"
raise ValueError(msg)
self.__mode = image.mode self.__mode = image.mode
self.__size = image.size self.__size = image.size
@ -278,18 +283,23 @@ def getimage(photo: PhotoImage) -> Image.Image:
return im return im
def _show(image, title): def _show(image: Image.Image, title: str | None) -> None:
"""Helper for the Image.show method.""" """Helper for the Image.show method."""
class UI(tkinter.Label): class UI(tkinter.Label):
def __init__(self, master, im): def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None:
self.image: BitmapImage | PhotoImage
if im.mode == "1": if im.mode == "1":
self.image = BitmapImage(im, foreground="white", master=master) self.image = BitmapImage(im, foreground="white", master=master)
else: else:
self.image = PhotoImage(im, master=master) self.image = PhotoImage(im, master=master)
super().__init__(master, image=self.image, bg="black", bd=0) if TYPE_CHECKING:
image = cast(tkinter._Image, self.image)
else:
image = self.image
super().__init__(master, image=image, bg="black", bd=0)
if not tkinter._default_root: if not getattr(tkinter, "_default_root"):
msg = "tkinter not initialized" msg = "tkinter not initialized"
raise OSError(msg) raise OSError(msg)
top = tkinter.Toplevel() top = tkinter.Toplevel()

View File

@ -70,11 +70,14 @@ class Dib:
""" """
def __init__( def __init__(
self, image: Image.Image | str, size: tuple[int, int] | list[int] | None = None self, image: Image.Image | str, size: tuple[int, int] | None = None
) -> None: ) -> None:
if isinstance(image, str): if isinstance(image, str):
mode = image mode = image
image = "" image = ""
if size is None:
msg = "If first argument is mode, size is required"
raise ValueError(msg)
else: else:
mode = image.mode mode = image.mode
size = image.size size = image.size
@ -105,7 +108,12 @@ class Dib:
result = self.image.expose(handle) result = self.image.expose(handle)
return result return result
def draw(self, handle, dst, src=None): def draw(
self,
handle,
dst: tuple[int, int, int, int],
src: tuple[int, int, int, int] | None = None,
):
""" """
Same as expose, but allows you to specify where to draw the image, and Same as expose, but allows you to specify where to draw the image, and
what part of it to draw. what part of it to draw.
@ -115,7 +123,7 @@ class Dib:
the destination have different sizes, the image is resized as the destination have different sizes, the image is resized as
necessary. necessary.
""" """
if not src: if src is None:
src = (0, 0) + self.size src = (0, 0) + self.size
if isinstance(handle, HWND): if isinstance(handle, HWND):
dc = self.image.getdc(handle) dc = self.image.getdc(handle)
@ -202,22 +210,22 @@ class Window:
title, self.__dispatcher, width or 0, height or 0 title, self.__dispatcher, width or 0, height or 0
) )
def __dispatcher(self, action, *args): def __dispatcher(self, action: str, *args):
return getattr(self, f"ui_handle_{action}")(*args) return getattr(self, f"ui_handle_{action}")(*args)
def ui_handle_clear(self, dc, x0, y0, x1, y1): def ui_handle_clear(self, dc, x0, y0, x1, y1) -> None:
pass pass
def ui_handle_damage(self, x0, y0, x1, y1): def ui_handle_damage(self, x0, y0, x1, y1) -> None:
pass pass
def ui_handle_destroy(self) -> None: def ui_handle_destroy(self) -> None:
pass pass
def ui_handle_repair(self, dc, x0, y0, x1, y1): def ui_handle_repair(self, dc, x0, y0, x1, y1) -> None:
pass pass
def ui_handle_resize(self, width, height): def ui_handle_resize(self, width, height) -> None:
pass pass
def mainloop(self) -> None: def mainloop(self) -> None:
@ -227,12 +235,12 @@ class Window:
class ImageWindow(Window): class ImageWindow(Window):
"""Create an image window which displays the given image.""" """Create an image window which displays the given image."""
def __init__(self, image, title="PIL"): def __init__(self, image, title: str = "PIL") -> None:
if not isinstance(image, Dib): if not isinstance(image, Dib):
image = Dib(image) image = Dib(image)
self.image = image self.image = image
width, height = image.size width, height = image.size
super().__init__(title, width=width, height=height) super().__init__(title, width=width, height=height)
def ui_handle_repair(self, dc, x0, y0, x1, y1): def ui_handle_repair(self, dc, x0, y0, x1, y1) -> None:
self.image.draw(dc, (x0, y0, x1, y1)) self.image.draw(dc, (x0, y0, x1, y1))

View File

@ -18,6 +18,7 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from io import BytesIO from io import BytesIO
from typing import cast
from . import Image, ImageFile from . import Image, ImageFile
from ._binary import i16be as i16 from ._binary import i16be as i16
@ -148,7 +149,7 @@ class IptcImageFile(ImageFile.ImageFile):
if tag == (8, 10): if tag == (8, 10):
self.tile = [("iptc", (0, 0) + self.size, offset, compression)] self.tile = [("iptc", (0, 0) + self.size, offset, compression)]
def load(self): def load(self) -> Image.core.PixelAccess | None:
if len(self.tile) != 1 or self.tile[0][0] != "iptc": if len(self.tile) != 1 or self.tile[0][0] != "iptc":
return ImageFile.ImageFile.load(self) return ImageFile.ImageFile.load(self)
@ -176,6 +177,7 @@ class IptcImageFile(ImageFile.ImageFile):
with Image.open(o) as _im: with Image.open(o) as _im:
_im.load() _im.load()
self.im = _im.im self.im = _im.im
return None
Image.register_open(IptcImageFile.format, IptcImageFile) Image.register_open(IptcImageFile.format, IptcImageFile)
@ -183,7 +185,7 @@ Image.register_open(IptcImageFile.format, IptcImageFile)
Image.register_extension(IptcImageFile.format, ".iim") Image.register_extension(IptcImageFile.format, ".iim")
def getiptcinfo(im): def getiptcinfo(im: ImageFile.ImageFile):
""" """
Get IPTC information from TIFF, JPEG, or IPTC file. Get IPTC information from TIFF, JPEG, or IPTC file.
@ -220,16 +222,17 @@ def getiptcinfo(im):
class FakeImage: class FakeImage:
pass pass
im = FakeImage() fake_im = FakeImage()
im.__class__ = IptcImageFile fake_im.__class__ = IptcImageFile # type: ignore[assignment]
iptc_im = cast(IptcImageFile, fake_im)
# parse the IPTC information chunk # parse the IPTC information chunk
im.info = {} iptc_im.info = {}
im.fp = BytesIO(data) iptc_im.fp = BytesIO(data)
try: try:
im._open() iptc_im._open()
except (IndexError, KeyError): except (IndexError, KeyError):
pass # expected failure pass # expected failure
return im.info return iptc_im.info

View File

@ -29,7 +29,7 @@ class BoxReader:
and to easily step into and read sub-boxes. and to easily step into and read sub-boxes.
""" """
def __init__(self, fp, length=-1): def __init__(self, fp: IO[bytes], length: int = -1) -> None:
self.fp = fp self.fp = fp
self.has_length = length >= 0 self.has_length = length >= 0
self.length = length self.length = length
@ -97,7 +97,7 @@ class BoxReader:
return tbox return tbox
def _parse_codestream(fp) -> tuple[tuple[int, int], str]: def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]:
"""Parse the JPEG 2000 codestream to extract the size and component """Parse the JPEG 2000 codestream to extract the size and component
count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" count from the SIZ marker segment, returning a PIL (size, mode) tuple."""
@ -137,7 +137,15 @@ def _res_to_dpi(num: int, denom: int, exp: int) -> float | None:
return (254 * num * (10**exp)) / (10000 * denom) return (254 * num * (10**exp)) / (10000 * denom)
def _parse_jp2_header(fp): def _parse_jp2_header(
fp: IO[bytes],
) -> tuple[
tuple[int, int],
str,
str | None,
tuple[float, float] | None,
ImagePalette.ImagePalette | None,
]:
"""Parse the JP2 header box to extract size, component count, """Parse the JP2 header box to extract size, component count,
color space information, and optionally DPI information, color space information, and optionally DPI information,
returning a (size, mode, mimetype, dpi) tuple.""" returning a (size, mode, mimetype, dpi) tuple."""
@ -155,6 +163,7 @@ def _parse_jp2_header(fp):
elif tbox == b"ftyp": elif tbox == b"ftyp":
if reader.read_fields(">4s")[0] == b"jpx ": if reader.read_fields(">4s")[0] == b"jpx ":
mimetype = "image/jpx" mimetype = "image/jpx"
assert header is not None
size = None size = None
mode = None mode = None
@ -168,6 +177,9 @@ def _parse_jp2_header(fp):
if tbox == b"ihdr": if tbox == b"ihdr":
height, width, nc, bpc = header.read_fields(">IIHB") height, width, nc, bpc = header.read_fields(">IIHB")
assert isinstance(height, int)
assert isinstance(width, int)
assert isinstance(bpc, int)
size = (width, height) size = (width, height)
if nc == 1 and (bpc & 0x7F) > 8: if nc == 1 and (bpc & 0x7F) > 8:
mode = "I;16" mode = "I;16"
@ -185,11 +197,21 @@ def _parse_jp2_header(fp):
mode = "CMYK" mode = "CMYK"
elif tbox == b"pclr" and mode in ("L", "LA"): elif tbox == b"pclr" and mode in ("L", "LA"):
ne, npc = header.read_fields(">HB") ne, npc = header.read_fields(">HB")
bitdepths = header.read_fields(">" + ("B" * npc)) assert isinstance(ne, int)
if max(bitdepths) <= 8: assert isinstance(npc, int)
max_bitdepth = 0
for bitdepth in header.read_fields(">" + ("B" * npc)):
assert isinstance(bitdepth, int)
if bitdepth > max_bitdepth:
max_bitdepth = bitdepth
if max_bitdepth <= 8:
palette = ImagePalette.ImagePalette() palette = ImagePalette.ImagePalette()
for i in range(ne): for i in range(ne):
palette.getcolor(header.read_fields(">" + ("B" * npc))) color: list[int] = []
for value in header.read_fields(">" + ("B" * npc)):
assert isinstance(value, int)
color.append(value)
palette.getcolor(tuple(color))
mode = "P" if mode == "L" else "PA" mode = "P" if mode == "L" else "PA"
elif tbox == b"res ": elif tbox == b"res ":
res = header.read_boxes() res = header.read_boxes()
@ -197,6 +219,12 @@ def _parse_jp2_header(fp):
tres = res.next_box_type() tres = res.next_box_type()
if tres == b"resc": if tres == b"resc":
vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB") vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB")
assert isinstance(vrcn, int)
assert isinstance(vrcd, int)
assert isinstance(hrcn, int)
assert isinstance(hrcd, int)
assert isinstance(vrce, int)
assert isinstance(hrce, int)
hres = _res_to_dpi(hrcn, hrcd, hrce) hres = _res_to_dpi(hrcn, hrcd, hrce)
vres = _res_to_dpi(vrcn, vrcd, vrce) vres = _res_to_dpi(vrcn, vrcd, vrce)
if hres is not None and vres is not None: if hres is not None and vres is not None:
@ -299,7 +327,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
def reduce(self, value): def reduce(self, value):
self._reduce = value self._reduce = value
def load(self): def load(self) -> Image.core.PixelAccess | None:
if self.tile and self._reduce: if self.tile and self._reduce:
power = 1 << self._reduce power = 1 << self._reduce
adjust = power >> 1 adjust = power >> 1

View File

@ -60,7 +60,7 @@ def Skip(self: JpegImageFile, marker: int) -> None:
ImageFile._safe_read(self.fp, n) ImageFile._safe_read(self.fp, n)
def APP(self, marker): def APP(self: JpegImageFile, marker: int) -> None:
# #
# Application marker. Store these in the APP dictionary. # Application marker. Store these in the APP dictionary.
# Also look for well-known application markers. # Also look for well-known application markers.
@ -133,13 +133,14 @@ def APP(self, marker):
offset += 4 offset += 4
data = s[offset : offset + size] data = s[offset : offset + size]
if code == 0x03ED: # ResolutionInfo if code == 0x03ED: # ResolutionInfo
data = { photoshop[code] = {
"XResolution": i32(data, 0) / 65536, "XResolution": i32(data, 0) / 65536,
"DisplayedUnitsX": i16(data, 4), "DisplayedUnitsX": i16(data, 4),
"YResolution": i32(data, 8) / 65536, "YResolution": i32(data, 8) / 65536,
"DisplayedUnitsY": i16(data, 12), "DisplayedUnitsY": i16(data, 12),
} }
photoshop[code] = data else:
photoshop[code] = data
offset += size offset += size
offset += offset & 1 # align offset += offset & 1 # align
except struct.error: except struct.error:
@ -338,6 +339,7 @@ class JpegImageFile(ImageFile.ImageFile):
# Create attributes # Create attributes
self.bits = self.layers = 0 self.bits = self.layers = 0
self._exif_offset = 0
# JPEG specifics (internal) # JPEG specifics (internal)
self.layer = [] self.layer = []
@ -498,17 +500,17 @@ class JpegImageFile(ImageFile.ImageFile):
): ):
self.info["dpi"] = 72, 72 self.info["dpi"] = 72, 72
def _getmp(self): def _getmp(self) -> dict[int, Any] | None:
return _getmp(self) return _getmp(self)
def _getexif(self) -> dict[str, Any] | None: def _getexif(self: JpegImageFile) -> dict[str, Any] | None:
if "exif" not in self.info: if "exif" not in self.info:
return None return None
return self.getexif()._get_merged_dict() return self.getexif()._get_merged_dict()
def _getmp(self): def _getmp(self: JpegImageFile) -> dict[int, Any] | None:
# Extract MP information. This method was inspired by the "highly # Extract MP information. This method was inspired by the "highly
# experimental" _getexif version that's been in use for years now, # experimental" _getexif version that's been in use for years now,
# itself based on the ImageFileDirectory class in the TIFF plugin. # itself based on the ImageFileDirectory class in the TIFF plugin.
@ -616,7 +618,7 @@ samplings = {
# fmt: on # fmt: on
def get_sampling(im): def get_sampling(im: Image.Image) -> int:
# There's no subsampling when images have only 1 layer # There's no subsampling when images have only 1 layer
# (grayscale images) or when they are CMYK (4 layers), # (grayscale images) or when they are CMYK (4 layers),
# so set subsampling to the default value. # so set subsampling to the default value.
@ -624,7 +626,7 @@ def get_sampling(im):
# NOTE: currently Pillow can't encode JPEG to YCCK format. # NOTE: currently Pillow can't encode JPEG to YCCK format.
# If YCCK support is added in the future, subsampling code will have # If YCCK support is added in the future, subsampling code will have
# to be updated (here and in JpegEncode.c) to deal with 4 layers. # to be updated (here and in JpegEncode.c) to deal with 4 layers.
if not hasattr(im, "layers") or im.layers in (1, 4): if not isinstance(im, JpegImageFile) or im.layers in (1, 4):
return -1 return -1
sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3] sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3]
return samplings.get(sampling, -1) return samplings.get(sampling, -1)
@ -683,7 +685,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
raise ValueError(msg) raise ValueError(msg)
subsampling = get_sampling(im) subsampling = get_sampling(im)
def validate_qtables(qtables): def validate_qtables(
qtables: (
str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None
)
) -> list[list[int]] | None:
if qtables is None: if qtables is None:
return qtables return qtables
if isinstance(qtables, str): if isinstance(qtables, str):
@ -713,12 +719,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if len(table) != 64: if len(table) != 64:
msg = "Invalid quantization table" msg = "Invalid quantization table"
raise TypeError(msg) raise TypeError(msg)
table = array.array("H", table) table_array = array.array("H", table)
except TypeError as e: except TypeError as e:
msg = "Invalid quantization table" msg = "Invalid quantization table"
raise ValueError(msg) from e raise ValueError(msg) from e
else: else:
qtables[idx] = list(table) qtables[idx] = list(table_array)
return qtables return qtables
if qtables == "keep": if qtables == "keep":
@ -825,11 +831,11 @@ def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
## ##
# Factory for making JPEG and MPO instances # Factory for making JPEG and MPO instances
def jpeg_factory(fp=None, filename=None): def jpeg_factory(fp: IO[bytes] | None = None, filename: str | bytes | None = None):
im = JpegImageFile(fp, filename) im = JpegImageFile(fp, filename)
try: try:
mpheader = im._getmp() mpheader = im._getmp()
if mpheader[45057] > 1: if mpheader is not None and mpheader[45057] > 1:
for segment, content in im.applist: for segment, content in im.applist:
if segment == "APP1" and b' hdrgm:Version="' in content: if segment == "APP1" and b' hdrgm:Version="' in content:
# Ultra HDR images are not yet supported # Ultra HDR images are not yet supported

View File

@ -21,7 +21,7 @@ from __future__ import annotations
import os import os
import struct import struct
from typing import IO from typing import IO, Any, cast
from . import ( from . import (
Image, Image,
@ -111,8 +111,11 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
JpegImagePlugin.JpegImageFile._open(self) JpegImagePlugin.JpegImageFile._open(self)
self._after_jpeg_open() self._after_jpeg_open()
def _after_jpeg_open(self, mpheader=None): def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None:
self.mpinfo = mpheader if mpheader is not None else self._getmp() self.mpinfo = mpheader if mpheader is not None else self._getmp()
if self.mpinfo is None:
msg = "Image appears to be a malformed MPO file"
raise ValueError(msg)
self.n_frames = self.mpinfo[0xB001] self.n_frames = self.mpinfo[0xB001]
self.__mpoffsets = [ self.__mpoffsets = [
mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002] mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002]
@ -159,7 +162,10 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
return self.__frame return self.__frame
@staticmethod @staticmethod
def adopt(jpeg_instance, mpheader=None): def adopt(
jpeg_instance: JpegImagePlugin.JpegImageFile,
mpheader: dict[int, Any] | None = None,
) -> MpoImageFile:
""" """
Transform the instance of JpegImageFile into Transform the instance of JpegImageFile into
an instance of MpoImageFile. an instance of MpoImageFile.
@ -171,8 +177,9 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
double call to _open. double call to _open.
""" """
jpeg_instance.__class__ = MpoImageFile jpeg_instance.__class__ = MpoImageFile
jpeg_instance._after_jpeg_open(mpheader) mpo_instance = cast(MpoImageFile, jpeg_instance)
return jpeg_instance mpo_instance._after_jpeg_open(mpheader)
return mpo_instance
# --------------------------------------------------------------------- # ---------------------------------------------------------------------

View File

@ -174,12 +174,15 @@ def _write_image(im, filename, existing_pdf, image_refs):
return image_ref, procset return image_ref, procset
def _save(im, fp, filename, save_all=False): def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
is_appending = im.encoderinfo.get("append", False) is_appending = im.encoderinfo.get("append", False)
filename_str = filename.decode() if isinstance(filename, bytes) else filename
if is_appending: if is_appending:
existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="r+b") existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b")
else: else:
existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="w+b") existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b")
dpi = im.encoderinfo.get("dpi") dpi = im.encoderinfo.get("dpi")
if dpi: if dpi:
@ -228,12 +231,7 @@ def _save(im, fp, filename, save_all=False):
for im in ims: for im in ims:
im_number_of_pages = 1 im_number_of_pages = 1
if save_all: if save_all:
try: im_number_of_pages = getattr(im, "n_frames", 1)
im_number_of_pages = im.n_frames
except AttributeError:
# Image format does not have n_frames.
# It is a single frame image
pass
number_of_pages += im_number_of_pages number_of_pages += im_number_of_pages
for i in range(im_number_of_pages): for i in range(im_number_of_pages):
image_refs.append(existing_pdf.next_object_id(0)) image_refs.append(existing_pdf.next_object_id(0))
@ -250,7 +248,9 @@ def _save(im, fp, filename, save_all=False):
page_number = 0 page_number = 0
for i, im_sequence in enumerate(ims): for i, im_sequence in enumerate(ims):
im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] im_pages: ImageSequence.Iterator | list[Image.Image] = (
ImageSequence.Iterator(im_sequence) if save_all else [im_sequence]
)
for im in im_pages: for im in im_pages:
image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) image_ref, procset = _write_image(im, filename, existing_pdf, image_refs)

View File

@ -8,7 +8,7 @@ import os
import re import re
import time import time
import zlib import zlib
from typing import TYPE_CHECKING, Any, NamedTuple, Union from typing import IO, TYPE_CHECKING, Any, NamedTuple, Union
# see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set
@ -62,7 +62,7 @@ PDFDocEncoding = {
} }
def decode_text(b): def decode_text(b: bytes) -> str:
if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE: if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE:
return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be") return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be")
else: else:
@ -99,7 +99,7 @@ class IndirectReference(IndirectReferenceTuple):
assert isinstance(other, IndirectReference) assert isinstance(other, IndirectReference)
return other.object_id == self.object_id and other.generation == self.generation return other.object_id == self.object_id and other.generation == self.generation
def __ne__(self, other): def __ne__(self, other: object) -> bool:
return not (self == other) return not (self == other)
def __hash__(self) -> int: def __hash__(self) -> int:
@ -112,13 +112,17 @@ class IndirectObjectDef(IndirectReference):
class XrefTable: class XrefTable:
def __init__(self): def __init__(self) -> None:
self.existing_entries = {} # object ID => (offset, generation) self.existing_entries: dict[int, tuple[int, int]] = (
self.new_entries = {} # object ID => (offset, generation) {}
) # object ID => (offset, generation)
self.new_entries: dict[int, tuple[int, int]] = (
{}
) # object ID => (offset, generation)
self.deleted_entries = {0: 65536} # object ID => generation self.deleted_entries = {0: 65536} # object ID => generation
self.reading_finished = False self.reading_finished = False
def __setitem__(self, key, value): def __setitem__(self, key: int, value: tuple[int, int]) -> None:
if self.reading_finished: if self.reading_finished:
self.new_entries[key] = value self.new_entries[key] = value
else: else:
@ -126,13 +130,13 @@ class XrefTable:
if key in self.deleted_entries: if key in self.deleted_entries:
del self.deleted_entries[key] del self.deleted_entries[key]
def __getitem__(self, key): def __getitem__(self, key: int) -> tuple[int, int]:
try: try:
return self.new_entries[key] return self.new_entries[key]
except KeyError: except KeyError:
return self.existing_entries[key] return self.existing_entries[key]
def __delitem__(self, key): def __delitem__(self, key: int) -> None:
if key in self.new_entries: if key in self.new_entries:
generation = self.new_entries[key][1] + 1 generation = self.new_entries[key][1] + 1
del self.new_entries[key] del self.new_entries[key]
@ -146,7 +150,7 @@ class XrefTable:
msg = f"object ID {key} cannot be deleted because it doesn't exist" msg = f"object ID {key} cannot be deleted because it doesn't exist"
raise IndexError(msg) raise IndexError(msg)
def __contains__(self, key): def __contains__(self, key: int) -> bool:
return key in self.existing_entries or key in self.new_entries return key in self.existing_entries or key in self.new_entries
def __len__(self) -> int: def __len__(self) -> int:
@ -156,19 +160,19 @@ class XrefTable:
| set(self.deleted_entries.keys()) | set(self.deleted_entries.keys())
) )
def keys(self): def keys(self) -> set[int]:
return ( return (
set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) set(self.existing_entries.keys()) - set(self.deleted_entries.keys())
) | set(self.new_entries.keys()) ) | set(self.new_entries.keys())
def write(self, f): def write(self, f: IO[bytes]) -> int:
keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys())) keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys()))
deleted_keys = sorted(set(self.deleted_entries.keys())) deleted_keys = sorted(set(self.deleted_entries.keys()))
startxref = f.tell() startxref = f.tell()
f.write(b"xref\n") f.write(b"xref\n")
while keys: while keys:
# find a contiguous sequence of object IDs # find a contiguous sequence of object IDs
prev = None prev: int | None = None
for index, key in enumerate(keys): for index, key in enumerate(keys):
if prev is None or prev + 1 == key: if prev is None or prev + 1 == key:
prev = key prev = key
@ -178,7 +182,7 @@ class XrefTable:
break break
else: else:
contiguous_keys = keys contiguous_keys = keys
keys = None keys = []
f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys))) f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys)))
for object_id in contiguous_keys: for object_id in contiguous_keys:
if object_id in self.new_entries: if object_id in self.new_entries:
@ -202,7 +206,9 @@ class XrefTable:
class PdfName: class PdfName:
def __init__(self, name): name: bytes
def __init__(self, name: PdfName | bytes | str) -> None:
if isinstance(name, PdfName): if isinstance(name, PdfName):
self.name = name.name self.name = name.name
elif isinstance(name, bytes): elif isinstance(name, bytes):
@ -213,7 +219,7 @@ class PdfName:
def name_as_str(self) -> str: def name_as_str(self) -> str:
return self.name.decode("us-ascii") return self.name.decode("us-ascii")
def __eq__(self, other): def __eq__(self, other: object) -> bool:
return ( return (
isinstance(other, PdfName) and other.name == self.name isinstance(other, PdfName) and other.name == self.name
) or other == self.name ) or other == self.name
@ -225,7 +231,7 @@ class PdfName:
return f"{self.__class__.__name__}({repr(self.name)})" return f"{self.__class__.__name__}({repr(self.name)})"
@classmethod @classmethod
def from_pdf_stream(cls, data): def from_pdf_stream(cls, data: bytes) -> PdfName:
return cls(PdfParser.interpret_name(data)) return cls(PdfParser.interpret_name(data))
allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"} allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"}
@ -252,13 +258,13 @@ else:
class PdfDict(_DictBase): class PdfDict(_DictBase):
def __setattr__(self, key, value): def __setattr__(self, key: str, value: Any) -> None:
if key == "data": if key == "data":
collections.UserDict.__setattr__(self, key, value) collections.UserDict.__setattr__(self, key, value)
else: else:
self[key.encode("us-ascii")] = value self[key.encode("us-ascii")] = value
def __getattr__(self, key): def __getattr__(self, key: str) -> str | time.struct_time:
try: try:
value = self[key.encode("us-ascii")] value = self[key.encode("us-ascii")]
except KeyError as e: except KeyError as e:
@ -300,7 +306,7 @@ class PdfDict(_DictBase):
class PdfBinary: class PdfBinary:
def __init__(self, data): def __init__(self, data: list[int] | bytes) -> None:
self.data = data self.data = data
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
@ -308,27 +314,27 @@ class PdfBinary:
class PdfStream: class PdfStream:
def __init__(self, dictionary, buf): def __init__(self, dictionary: PdfDict, buf: bytes) -> None:
self.dictionary = dictionary self.dictionary = dictionary
self.buf = buf self.buf = buf
def decode(self): def decode(self) -> bytes:
try: try:
filter = self.dictionary.Filter filter = self.dictionary[b"Filter"]
except AttributeError: except KeyError:
return self.buf return self.buf
if filter == b"FlateDecode": if filter == b"FlateDecode":
try: try:
expected_length = self.dictionary.DL expected_length = self.dictionary[b"DL"]
except AttributeError: except KeyError:
expected_length = self.dictionary.Length expected_length = self.dictionary[b"Length"]
return zlib.decompress(self.buf, bufsize=int(expected_length)) return zlib.decompress(self.buf, bufsize=int(expected_length))
else: else:
msg = f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" msg = f"stream filter {repr(filter)} unknown/unsupported"
raise NotImplementedError(msg) raise NotImplementedError(msg)
def pdf_repr(x): def pdf_repr(x: Any) -> bytes:
if x is True: if x is True:
return b"true" return b"true"
elif x is False: elif x is False:
@ -363,12 +369,19 @@ class PdfParser:
Supports PDF up to 1.4 Supports PDF up to 1.4
""" """
def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): def __init__(
self,
filename: str | None = None,
f: IO[bytes] | None = None,
buf: bytes | bytearray | None = None,
start_offset: int = 0,
mode: str = "rb",
) -> None:
if buf and f: if buf and f:
msg = "specify buf or f or filename, but not both buf and f" msg = "specify buf or f or filename, but not both buf and f"
raise RuntimeError(msg) raise RuntimeError(msg)
self.filename = filename self.filename = filename
self.buf = buf self.buf: bytes | bytearray | mmap.mmap | None = buf
self.f = f self.f = f
self.start_offset = start_offset self.start_offset = start_offset
self.should_close_buf = False self.should_close_buf = False
@ -377,12 +390,16 @@ class PdfParser:
self.f = f = open(filename, mode) self.f = f = open(filename, mode)
self.should_close_file = True self.should_close_file = True
if f is not None: if f is not None:
self.buf = buf = self.get_buf_from_file(f) self.buf = self.get_buf_from_file(f)
self.should_close_buf = True self.should_close_buf = True
if not filename and hasattr(f, "name"): if not filename and hasattr(f, "name"):
self.filename = f.name self.filename = f.name
self.cached_objects = {} self.cached_objects: dict[IndirectReference, Any] = {}
if buf: self.root_ref: IndirectReference | None
self.info_ref: IndirectReference | None
self.pages_ref: IndirectReference | None
self.last_xref_section_offset: int | None
if self.buf:
self.read_pdf_info() self.read_pdf_info()
else: else:
self.file_size_total = self.file_size_this = 0 self.file_size_total = self.file_size_this = 0
@ -390,12 +407,12 @@ class PdfParser:
self.root_ref = None self.root_ref = None
self.info = PdfDict() self.info = PdfDict()
self.info_ref = None self.info_ref = None
self.page_tree_root = {} self.page_tree_root = PdfDict()
self.pages = [] self.pages: list[IndirectReference] = []
self.orig_pages = [] self.orig_pages: list[IndirectReference] = []
self.pages_ref = None self.pages_ref = None
self.last_xref_section_offset = None self.last_xref_section_offset = None
self.trailer_dict = {} self.trailer_dict: dict[bytes, Any] = {}
self.xref_table = XrefTable() self.xref_table = XrefTable()
self.xref_table.reading_finished = True self.xref_table.reading_finished = True
if f: if f:
@ -412,10 +429,8 @@ class PdfParser:
self.seek_end() self.seek_end()
def close_buf(self) -> None: def close_buf(self) -> None:
try: if isinstance(self.buf, mmap.mmap):
self.buf.close() self.buf.close()
except AttributeError:
pass
self.buf = None self.buf = None
def close(self) -> None: def close(self) -> None:
@ -426,15 +441,19 @@ class PdfParser:
self.f = None self.f = None
def seek_end(self) -> None: def seek_end(self) -> None:
assert self.f is not None
self.f.seek(0, os.SEEK_END) self.f.seek(0, os.SEEK_END)
def write_header(self) -> None: def write_header(self) -> None:
assert self.f is not None
self.f.write(b"%PDF-1.4\n") self.f.write(b"%PDF-1.4\n")
def write_comment(self, s): def write_comment(self, s: str) -> None:
assert self.f is not None
self.f.write(f"% {s}\n".encode()) self.f.write(f"% {s}\n".encode())
def write_catalog(self) -> IndirectReference: def write_catalog(self) -> IndirectReference:
assert self.f is not None
self.del_root() self.del_root()
self.root_ref = self.next_object_id(self.f.tell()) self.root_ref = self.next_object_id(self.f.tell())
self.pages_ref = self.next_object_id(0) self.pages_ref = self.next_object_id(0)
@ -477,7 +496,10 @@ class PdfParser:
pages_tree_node_ref = pages_tree_node.get(b"Parent", None) pages_tree_node_ref = pages_tree_node.get(b"Parent", None)
self.orig_pages = [] self.orig_pages = []
def write_xref_and_trailer(self, new_root_ref=None): def write_xref_and_trailer(
self, new_root_ref: IndirectReference | None = None
) -> None:
assert self.f is not None
if new_root_ref: if new_root_ref:
self.del_root() self.del_root()
self.root_ref = new_root_ref self.root_ref = new_root_ref
@ -485,7 +507,10 @@ class PdfParser:
self.info_ref = self.write_obj(None, self.info) self.info_ref = self.write_obj(None, self.info)
start_xref = self.xref_table.write(self.f) start_xref = self.xref_table.write(self.f)
num_entries = len(self.xref_table) num_entries = len(self.xref_table)
trailer_dict = {b"Root": self.root_ref, b"Size": num_entries} trailer_dict: dict[str | bytes, Any] = {
b"Root": self.root_ref,
b"Size": num_entries,
}
if self.last_xref_section_offset is not None: if self.last_xref_section_offset is not None:
trailer_dict[b"Prev"] = self.last_xref_section_offset trailer_dict[b"Prev"] = self.last_xref_section_offset
if self.info: if self.info:
@ -497,16 +522,20 @@ class PdfParser:
+ b"\nstartxref\n%d\n%%%%EOF" % start_xref + b"\nstartxref\n%d\n%%%%EOF" % start_xref
) )
def write_page(self, ref, *objs, **dict_obj): def write_page(
if isinstance(ref, int): self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any
ref = self.pages[ref] ) -> IndirectReference:
obj_ref = self.pages[ref] if isinstance(ref, int) else ref
if "Type" not in dict_obj: if "Type" not in dict_obj:
dict_obj["Type"] = PdfName(b"Page") dict_obj["Type"] = PdfName(b"Page")
if "Parent" not in dict_obj: if "Parent" not in dict_obj:
dict_obj["Parent"] = self.pages_ref dict_obj["Parent"] = self.pages_ref
return self.write_obj(ref, *objs, **dict_obj) return self.write_obj(obj_ref, *objs, **dict_obj)
def write_obj(self, ref, *objs, **dict_obj): def write_obj(
self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any
) -> IndirectReference:
assert self.f is not None
f = self.f f = self.f
if ref is None: if ref is None:
ref = self.next_object_id(f.tell()) ref = self.next_object_id(f.tell())
@ -534,7 +563,7 @@ class PdfParser:
del self.xref_table[self.root[b"Pages"].object_id] del self.xref_table[self.root[b"Pages"].object_id]
@staticmethod @staticmethod
def get_buf_from_file(f): def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap:
if hasattr(f, "getbuffer"): if hasattr(f, "getbuffer"):
return f.getbuffer() return f.getbuffer()
elif hasattr(f, "getvalue"): elif hasattr(f, "getvalue"):
@ -546,10 +575,15 @@ class PdfParser:
return b"" return b""
def read_pdf_info(self) -> None: def read_pdf_info(self) -> None:
assert self.buf is not None
self.file_size_total = len(self.buf) self.file_size_total = len(self.buf)
self.file_size_this = self.file_size_total - self.start_offset self.file_size_this = self.file_size_total - self.start_offset
self.read_trailer() self.read_trailer()
check_format_condition(
self.trailer_dict.get(b"Root") is not None, "Root is missing"
)
self.root_ref = self.trailer_dict[b"Root"] self.root_ref = self.trailer_dict[b"Root"]
assert self.root_ref is not None
self.info_ref = self.trailer_dict.get(b"Info", None) self.info_ref = self.trailer_dict.get(b"Info", None)
self.root = PdfDict(self.read_indirect(self.root_ref)) self.root = PdfDict(self.read_indirect(self.root_ref))
if self.info_ref is None: if self.info_ref is None:
@ -560,12 +594,15 @@ class PdfParser:
check_format_condition( check_format_condition(
self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog" self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog"
) )
check_format_condition(b"Pages" in self.root, "/Pages missing in Root") check_format_condition(
self.root.get(b"Pages") is not None, "/Pages missing in Root"
)
check_format_condition( check_format_condition(
isinstance(self.root[b"Pages"], IndirectReference), isinstance(self.root[b"Pages"], IndirectReference),
"/Pages in Root is not an indirect reference", "/Pages in Root is not an indirect reference",
) )
self.pages_ref = self.root[b"Pages"] self.pages_ref = self.root[b"Pages"]
assert self.pages_ref is not None
self.page_tree_root = self.read_indirect(self.pages_ref) self.page_tree_root = self.read_indirect(self.pages_ref)
self.pages = self.linearize_page_tree(self.page_tree_root) self.pages = self.linearize_page_tree(self.page_tree_root)
# save the original list of page references # save the original list of page references
@ -573,7 +610,7 @@ class PdfParser:
# and we need to rewrite the pages and their list # and we need to rewrite the pages and their list
self.orig_pages = self.pages[:] self.orig_pages = self.pages[:]
def next_object_id(self, offset=None): def next_object_id(self, offset: int | None = None) -> IndirectReference:
try: try:
# TODO: support reuse of deleted objects # TODO: support reuse of deleted objects
reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) reference = IndirectReference(max(self.xref_table.keys()) + 1, 0)
@ -623,12 +660,13 @@ class PdfParser:
re.DOTALL, re.DOTALL,
) )
def read_trailer(self): def read_trailer(self) -> None:
assert self.buf is not None
search_start_offset = len(self.buf) - 16384 search_start_offset = len(self.buf) - 16384
if search_start_offset < self.start_offset: if search_start_offset < self.start_offset:
search_start_offset = self.start_offset search_start_offset = self.start_offset
m = self.re_trailer_end.search(self.buf, search_start_offset) m = self.re_trailer_end.search(self.buf, search_start_offset)
check_format_condition(m, "trailer end not found") check_format_condition(m is not None, "trailer end not found")
# make sure we found the LAST trailer # make sure we found the LAST trailer
last_match = m last_match = m
while m: while m:
@ -636,6 +674,7 @@ class PdfParser:
m = self.re_trailer_end.search(self.buf, m.start() + 16) m = self.re_trailer_end.search(self.buf, m.start() + 16)
if not m: if not m:
m = last_match m = last_match
assert m is not None
trailer_data = m.group(1) trailer_data = m.group(1)
self.last_xref_section_offset = int(m.group(2)) self.last_xref_section_offset = int(m.group(2))
self.trailer_dict = self.interpret_trailer(trailer_data) self.trailer_dict = self.interpret_trailer(trailer_data)
@ -644,12 +683,14 @@ class PdfParser:
if b"Prev" in self.trailer_dict: if b"Prev" in self.trailer_dict:
self.read_prev_trailer(self.trailer_dict[b"Prev"]) self.read_prev_trailer(self.trailer_dict[b"Prev"])
def read_prev_trailer(self, xref_section_offset): def read_prev_trailer(self, xref_section_offset: int) -> None:
assert self.buf is not None
trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset)
m = self.re_trailer_prev.search( m = self.re_trailer_prev.search(
self.buf[trailer_offset : trailer_offset + 16384] self.buf[trailer_offset : trailer_offset + 16384]
) )
check_format_condition(m, "previous trailer not found") check_format_condition(m is not None, "previous trailer not found")
assert m is not None
trailer_data = m.group(1) trailer_data = m.group(1)
check_format_condition( check_format_condition(
int(m.group(2)) == xref_section_offset, int(m.group(2)) == xref_section_offset,
@ -670,7 +711,7 @@ class PdfParser:
re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional) re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional)
@classmethod @classmethod
def interpret_trailer(cls, trailer_data): def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]:
trailer = {} trailer = {}
offset = 0 offset = 0
while True: while True:
@ -678,14 +719,18 @@ class PdfParser:
if not m: if not m:
m = cls.re_dict_end.match(trailer_data, offset) m = cls.re_dict_end.match(trailer_data, offset)
check_format_condition( check_format_condition(
m and m.end() == len(trailer_data), m is not None and m.end() == len(trailer_data),
"name not found in trailer, remaining data: " "name not found in trailer, remaining data: "
+ repr(trailer_data[offset:]), + repr(trailer_data[offset:]),
) )
break break
key = cls.interpret_name(m.group(1)) key = cls.interpret_name(m.group(1))
value, offset = cls.get_value(trailer_data, m.end()) assert isinstance(key, bytes)
value, value_offset = cls.get_value(trailer_data, m.end())
trailer[key] = value trailer[key] = value
if value_offset is None:
break
offset = value_offset
check_format_condition( check_format_condition(
b"Size" in trailer and isinstance(trailer[b"Size"], int), b"Size" in trailer and isinstance(trailer[b"Size"], int),
"/Size not in trailer or not an integer", "/Size not in trailer or not an integer",
@ -699,7 +744,7 @@ class PdfParser:
re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?") re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?")
@classmethod @classmethod
def interpret_name(cls, raw, as_text=False): def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes:
name = b"" name = b""
for m in cls.re_hashes_in_name.finditer(raw): for m in cls.re_hashes_in_name.finditer(raw):
if m.group(3): if m.group(3):
@ -761,7 +806,13 @@ class PdfParser:
) )
@classmethod @classmethod
def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): def get_value(
cls,
data: bytes | bytearray | mmap.mmap,
offset: int,
expect_indirect: IndirectReference | None = None,
max_nesting: int = -1,
) -> tuple[Any, int | None]:
if max_nesting == 0: if max_nesting == 0:
return None, None return None, None
m = cls.re_comment.match(data, offset) m = cls.re_comment.match(data, offset)
@ -783,11 +834,16 @@ class PdfParser:
== IndirectReference(int(m.group(1)), int(m.group(2))), == IndirectReference(int(m.group(1)), int(m.group(2))),
"indirect object definition different than expected", "indirect object definition different than expected",
) )
object, offset = cls.get_value(data, m.end(), max_nesting=max_nesting - 1) object, object_offset = cls.get_value(
if offset is None: data, m.end(), max_nesting=max_nesting - 1
)
if object_offset is None:
return object, None return object, None
m = cls.re_indirect_def_end.match(data, offset) m = cls.re_indirect_def_end.match(data, object_offset)
check_format_condition(m, "indirect object definition end not found") check_format_condition(
m is not None, "indirect object definition end not found"
)
assert m is not None
return object, m.end() return object, m.end()
check_format_condition( check_format_condition(
not expect_indirect, "indirect object definition not found" not expect_indirect, "indirect object definition not found"
@ -806,46 +862,53 @@ class PdfParser:
m = cls.re_dict_start.match(data, offset) m = cls.re_dict_start.match(data, offset)
if m: if m:
offset = m.end() offset = m.end()
result = {} result: dict[Any, Any] = {}
m = cls.re_dict_end.match(data, offset) m = cls.re_dict_end.match(data, offset)
current_offset: int | None = offset
while not m: while not m:
key, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) assert current_offset is not None
if offset is None: key, current_offset = cls.get_value(
data, current_offset, max_nesting=max_nesting - 1
)
if current_offset is None:
return result, None return result, None
value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) value, current_offset = cls.get_value(
data, current_offset, max_nesting=max_nesting - 1
)
result[key] = value result[key] = value
if offset is None: if current_offset is None:
return result, None return result, None
m = cls.re_dict_end.match(data, offset) m = cls.re_dict_end.match(data, current_offset)
offset = m.end() current_offset = m.end()
m = cls.re_stream_start.match(data, offset) m = cls.re_stream_start.match(data, current_offset)
if m: if m:
try: stream_len = result.get(b"Length")
stream_len_str = result.get(b"Length") if stream_len is None or not isinstance(stream_len, int):
stream_len = int(stream_len_str) msg = f"bad or missing Length in stream dict ({stream_len})"
except (TypeError, ValueError) as e: raise PdfFormatError(msg)
msg = f"bad or missing Length in stream dict ({stream_len_str})"
raise PdfFormatError(msg) from e
stream_data = data[m.end() : m.end() + stream_len] stream_data = data[m.end() : m.end() + stream_len]
m = cls.re_stream_end.match(data, m.end() + stream_len) m = cls.re_stream_end.match(data, m.end() + stream_len)
check_format_condition(m, "stream end not found") check_format_condition(m is not None, "stream end not found")
offset = m.end() assert m is not None
result = PdfStream(PdfDict(result), stream_data) current_offset = m.end()
else: return PdfStream(PdfDict(result), stream_data), current_offset
result = PdfDict(result) return PdfDict(result), current_offset
return result, offset
m = cls.re_array_start.match(data, offset) m = cls.re_array_start.match(data, offset)
if m: if m:
offset = m.end() offset = m.end()
result = [] results = []
m = cls.re_array_end.match(data, offset) m = cls.re_array_end.match(data, offset)
current_offset = offset
while not m: while not m:
value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) assert current_offset is not None
result.append(value) value, current_offset = cls.get_value(
if offset is None: data, current_offset, max_nesting=max_nesting - 1
return result, None )
m = cls.re_array_end.match(data, offset) results.append(value)
return result, m.end() if current_offset is None:
return results, None
m = cls.re_array_end.match(data, current_offset)
return results, m.end()
m = cls.re_null.match(data, offset) m = cls.re_null.match(data, offset)
if m: if m:
return None, m.end() return None, m.end()
@ -905,7 +968,9 @@ class PdfParser:
} }
@classmethod @classmethod
def get_literal_string(cls, data, offset): def get_literal_string(
cls, data: bytes | bytearray | mmap.mmap, offset: int
) -> tuple[bytes, int]:
nesting_depth = 0 nesting_depth = 0
result = bytearray() result = bytearray()
for m in cls.re_lit_str_token.finditer(data, offset): for m in cls.re_lit_str_token.finditer(data, offset):
@ -941,12 +1006,14 @@ class PdfParser:
) )
re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)")
def read_xref_table(self, xref_section_offset): def read_xref_table(self, xref_section_offset: int) -> int:
assert self.buf is not None
subsection_found = False subsection_found = False
m = self.re_xref_section_start.match( m = self.re_xref_section_start.match(
self.buf, xref_section_offset + self.start_offset self.buf, xref_section_offset + self.start_offset
) )
check_format_condition(m, "xref section start not found") check_format_condition(m is not None, "xref section start not found")
assert m is not None
offset = m.end() offset = m.end()
while True: while True:
m = self.re_xref_subsection_start.match(self.buf, offset) m = self.re_xref_subsection_start.match(self.buf, offset)
@ -961,7 +1028,8 @@ class PdfParser:
num_objects = int(m.group(2)) num_objects = int(m.group(2))
for i in range(first_object, first_object + num_objects): for i in range(first_object, first_object + num_objects):
m = self.re_xref_entry.match(self.buf, offset) m = self.re_xref_entry.match(self.buf, offset)
check_format_condition(m, "xref entry not found") check_format_condition(m is not None, "xref entry not found")
assert m is not None
offset = m.end() offset = m.end()
is_free = m.group(3) == b"f" is_free = m.group(3) == b"f"
if not is_free: if not is_free:
@ -971,13 +1039,14 @@ class PdfParser:
self.xref_table[i] = new_entry self.xref_table[i] = new_entry
return offset return offset
def read_indirect(self, ref, max_nesting=-1): def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any:
offset, generation = self.xref_table[ref[0]] offset, generation = self.xref_table[ref[0]]
check_format_condition( check_format_condition(
generation == ref[1], generation == ref[1],
f"expected to find generation {ref[1]} for object ID {ref[0]} in xref " f"expected to find generation {ref[1]} for object ID {ref[0]} in xref "
f"table, instead found generation {generation} at offset {offset}", f"table, instead found generation {generation} at offset {offset}",
) )
assert self.buf is not None
value = self.get_value( value = self.get_value(
self.buf, self.buf,
offset + self.start_offset, offset + self.start_offset,
@ -987,14 +1056,15 @@ class PdfParser:
self.cached_objects[ref] = value self.cached_objects[ref] = value
return value return value
def linearize_page_tree(self, node=None): def linearize_page_tree(
if node is None: self, node: PdfDict | None = None
node = self.page_tree_root ) -> list[IndirectReference]:
page_node = node if node is not None else self.page_tree_root
check_format_condition( check_format_condition(
node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages"
) )
pages = [] pages = []
for kid in node[b"Kids"]: for kid in page_node[b"Kids"]:
kid_object = self.read_indirect(kid) kid_object = self.read_indirect(kid)
if kid_object[b"Type"] == b"Page": if kid_object[b"Type"] == b"Page":
pages.append(kid) pages.append(kid)

View File

@ -39,7 +39,7 @@ import struct
import warnings import warnings
import zlib import zlib
from enum import IntEnum from enum import IntEnum
from typing import IO, TYPE_CHECKING, Any, NoReturn from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
from ._binary import i16be as i16 from ._binary import i16be as i16
@ -144,7 +144,7 @@ def _safe_zlib_decompress(s):
return plaintext return plaintext
def _crc32(data, seed=0): def _crc32(data: bytes, seed: int = 0) -> int:
return zlib.crc32(data, seed) & 0xFFFFFFFF return zlib.crc32(data, seed) & 0xFFFFFFFF
@ -191,7 +191,7 @@ class ChunkStream:
assert self.queue is not None assert self.queue is not None
self.queue.append((cid, pos, length)) self.queue.append((cid, pos, length))
def call(self, cid, pos, length): def call(self, cid: bytes, pos: int, length: int) -> bytes:
"""Call the appropriate chunk handler""" """Call the appropriate chunk handler"""
logger.debug("STREAM %r %s %s", cid, pos, length) logger.debug("STREAM %r %s %s", cid, pos, length)
@ -230,6 +230,7 @@ class ChunkStream:
cids = [] cids = []
assert self.fp is not None
while True: while True:
try: try:
cid, pos, length = self.read() cid, pos, length = self.read()
@ -407,6 +408,7 @@ class PngStream(ChunkStream):
def chunk_iCCP(self, pos: int, length: int) -> bytes: def chunk_iCCP(self, pos: int, length: int) -> bytes:
# ICC profile # ICC profile
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
# according to PNG spec, the iCCP chunk contains: # according to PNG spec, the iCCP chunk contains:
# Profile name 1-79 bytes (character string) # Profile name 1-79 bytes (character string)
@ -434,6 +436,7 @@ class PngStream(ChunkStream):
def chunk_IHDR(self, pos: int, length: int) -> bytes: def chunk_IHDR(self, pos: int, length: int) -> bytes:
# image header # image header
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if length < 13: if length < 13:
if ImageFile.LOAD_TRUNCATED_IMAGES: if ImageFile.LOAD_TRUNCATED_IMAGES:
@ -471,6 +474,7 @@ class PngStream(ChunkStream):
def chunk_PLTE(self, pos: int, length: int) -> bytes: def chunk_PLTE(self, pos: int, length: int) -> bytes:
# palette # palette
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if self.im_mode == "P": if self.im_mode == "P":
self.im_palette = "RGB", s self.im_palette = "RGB", s
@ -478,6 +482,7 @@ class PngStream(ChunkStream):
def chunk_tRNS(self, pos: int, length: int) -> bytes: def chunk_tRNS(self, pos: int, length: int) -> bytes:
# transparency # transparency
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if self.im_mode == "P": if self.im_mode == "P":
if _simple_palette.match(s): if _simple_palette.match(s):
@ -498,6 +503,7 @@ class PngStream(ChunkStream):
def chunk_gAMA(self, pos: int, length: int) -> bytes: def chunk_gAMA(self, pos: int, length: int) -> bytes:
# gamma setting # gamma setting
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
self.im_info["gamma"] = i32(s) / 100000.0 self.im_info["gamma"] = i32(s) / 100000.0
return s return s
@ -506,6 +512,7 @@ class PngStream(ChunkStream):
# chromaticity, 8 unsigned ints, actual value is scaled by 100,000 # chromaticity, 8 unsigned ints, actual value is scaled by 100,000
# WP x,y, Red x,y, Green x,y Blue x,y # WP x,y, Red x,y, Green x,y Blue x,y
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
raw_vals = struct.unpack(">%dI" % (len(s) // 4), s) raw_vals = struct.unpack(">%dI" % (len(s) // 4), s)
self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals) self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals)
@ -518,6 +525,7 @@ class PngStream(ChunkStream):
# 2 saturation # 2 saturation
# 3 absolute colorimetric # 3 absolute colorimetric
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if length < 1: if length < 1:
if ImageFile.LOAD_TRUNCATED_IMAGES: if ImageFile.LOAD_TRUNCATED_IMAGES:
@ -529,6 +537,7 @@ class PngStream(ChunkStream):
def chunk_pHYs(self, pos: int, length: int) -> bytes: def chunk_pHYs(self, pos: int, length: int) -> bytes:
# pixels per unit # pixels per unit
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if length < 9: if length < 9:
if ImageFile.LOAD_TRUNCATED_IMAGES: if ImageFile.LOAD_TRUNCATED_IMAGES:
@ -546,6 +555,7 @@ class PngStream(ChunkStream):
def chunk_tEXt(self, pos: int, length: int) -> bytes: def chunk_tEXt(self, pos: int, length: int) -> bytes:
# text # text
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
try: try:
k, v = s.split(b"\0", 1) k, v = s.split(b"\0", 1)
@ -554,17 +564,18 @@ class PngStream(ChunkStream):
k = s k = s
v = b"" v = b""
if k: if k:
k = k.decode("latin-1", "strict") k_str = k.decode("latin-1", "strict")
v_str = v.decode("latin-1", "replace") v_str = v.decode("latin-1", "replace")
self.im_info[k] = v if k == "exif" else v_str self.im_info[k_str] = v if k == b"exif" else v_str
self.im_text[k] = v_str self.im_text[k_str] = v_str
self.check_text_memory(len(v_str)) self.check_text_memory(len(v_str))
return s return s
def chunk_zTXt(self, pos: int, length: int) -> bytes: def chunk_zTXt(self, pos: int, length: int) -> bytes:
# compressed text # compressed text
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
try: try:
k, v = s.split(b"\0", 1) k, v = s.split(b"\0", 1)
@ -589,16 +600,17 @@ class PngStream(ChunkStream):
v = b"" v = b""
if k: if k:
k = k.decode("latin-1", "strict") k_str = k.decode("latin-1", "strict")
v = v.decode("latin-1", "replace") v_str = v.decode("latin-1", "replace")
self.im_info[k] = self.im_text[k] = v self.im_info[k_str] = self.im_text[k_str] = v_str
self.check_text_memory(len(v)) self.check_text_memory(len(v_str))
return s return s
def chunk_iTXt(self, pos: int, length: int) -> bytes: def chunk_iTXt(self, pos: int, length: int) -> bytes:
# international text # international text
assert self.fp is not None
r = s = ImageFile._safe_read(self.fp, length) r = s = ImageFile._safe_read(self.fp, length)
try: try:
k, r = r.split(b"\0", 1) k, r = r.split(b"\0", 1)
@ -627,25 +639,27 @@ class PngStream(ChunkStream):
if k == b"XML:com.adobe.xmp": if k == b"XML:com.adobe.xmp":
self.im_info["xmp"] = v self.im_info["xmp"] = v
try: try:
k = k.decode("latin-1", "strict") k_str = k.decode("latin-1", "strict")
lang = lang.decode("utf-8", "strict") lang_str = lang.decode("utf-8", "strict")
tk = tk.decode("utf-8", "strict") tk_str = tk.decode("utf-8", "strict")
v = v.decode("utf-8", "strict") v_str = v.decode("utf-8", "strict")
except UnicodeError: except UnicodeError:
return s return s
self.im_info[k] = self.im_text[k] = iTXt(v, lang, tk) self.im_info[k_str] = self.im_text[k_str] = iTXt(v_str, lang_str, tk_str)
self.check_text_memory(len(v)) self.check_text_memory(len(v_str))
return s return s
def chunk_eXIf(self, pos: int, length: int) -> bytes: def chunk_eXIf(self, pos: int, length: int) -> bytes:
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
self.im_info["exif"] = b"Exif\x00\x00" + s self.im_info["exif"] = b"Exif\x00\x00" + s
return s return s
# APNG chunks # APNG chunks
def chunk_acTL(self, pos: int, length: int) -> bytes: def chunk_acTL(self, pos: int, length: int) -> bytes:
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if length < 8: if length < 8:
if ImageFile.LOAD_TRUNCATED_IMAGES: if ImageFile.LOAD_TRUNCATED_IMAGES:
@ -666,6 +680,7 @@ class PngStream(ChunkStream):
return s return s
def chunk_fcTL(self, pos: int, length: int) -> bytes: def chunk_fcTL(self, pos: int, length: int) -> bytes:
assert self.fp is not None
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
if length < 26: if length < 26:
if ImageFile.LOAD_TRUNCATED_IMAGES: if ImageFile.LOAD_TRUNCATED_IMAGES:
@ -695,6 +710,7 @@ class PngStream(ChunkStream):
return s return s
def chunk_fdAT(self, pos: int, length: int) -> bytes: def chunk_fdAT(self, pos: int, length: int) -> bytes:
assert self.fp is not None
if length < 4: if length < 4:
if ImageFile.LOAD_TRUNCATED_IMAGES: if ImageFile.LOAD_TRUNCATED_IMAGES:
s = ImageFile._safe_read(self.fp, length) s = ImageFile._safe_read(self.fp, length)
@ -1075,21 +1091,21 @@ _OUTMODES = {
} }
def putchunk(fp, cid, *data): def putchunk(fp: IO[bytes], cid: bytes, *data: bytes) -> None:
"""Write a PNG chunk (including CRC field)""" """Write a PNG chunk (including CRC field)"""
data = b"".join(data) byte_data = b"".join(data)
fp.write(o32(len(data)) + cid) fp.write(o32(len(byte_data)) + cid)
fp.write(data) fp.write(byte_data)
crc = _crc32(data, _crc32(cid)) crc = _crc32(byte_data, _crc32(cid))
fp.write(o32(crc)) fp.write(o32(crc))
class _idat: class _idat:
# wrap output from the encoder in IDAT chunks # wrap output from the encoder in IDAT chunks
def __init__(self, fp, chunk): def __init__(self, fp, chunk) -> None:
self.fp = fp self.fp = fp
self.chunk = chunk self.chunk = chunk
@ -1100,7 +1116,7 @@ class _idat:
class _fdat: class _fdat:
# wrap encoder output in fdAT chunks # wrap encoder output in fdAT chunks
def __init__(self, fp, chunk, seq_num): def __init__(self, fp: IO[bytes], chunk, seq_num: int) -> None:
self.fp = fp self.fp = fp
self.chunk = chunk self.chunk = chunk
self.seq_num = seq_num self.seq_num = seq_num
@ -1110,7 +1126,21 @@ class _fdat:
self.seq_num += 1 self.seq_num += 1
def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_images): class _Frame(NamedTuple):
im: Image.Image
bbox: tuple[int, int, int, int] | None
encoderinfo: dict[str, Any]
def _write_multiple_frames(
im: Image.Image,
fp: IO[bytes],
chunk,
mode: str,
rawmode: str,
default_image: Image.Image | None,
append_images: list[Image.Image],
) -> Image.Image | None:
duration = im.encoderinfo.get("duration") duration = im.encoderinfo.get("duration")
loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) loop = im.encoderinfo.get("loop", im.info.get("loop", 0))
disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE))
@ -1126,7 +1156,7 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
for imSequence in imSequences: for imSequence in imSequences:
total += getattr(imSequence, "n_frames", 1) total += getattr(imSequence, "n_frames", 1)
im_frames = [] im_frames: list[_Frame] = []
frame_count = 0 frame_count = 0
for i, imSequence in enumerate(imSequences): for i, imSequence in enumerate(imSequences):
for im_frame in ImageSequence.Iterator(imSequence): for im_frame in ImageSequence.Iterator(imSequence):
@ -1147,24 +1177,24 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
if im_frames: if im_frames:
previous = im_frames[-1] previous = im_frames[-1]
prev_disposal = previous["encoderinfo"].get("disposal") prev_disposal = previous.encoderinfo.get("disposal")
prev_blend = previous["encoderinfo"].get("blend") prev_blend = previous.encoderinfo.get("blend")
if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2:
prev_disposal = Disposal.OP_BACKGROUND prev_disposal = Disposal.OP_BACKGROUND
if prev_disposal == Disposal.OP_BACKGROUND: if prev_disposal == Disposal.OP_BACKGROUND:
base_im = previous["im"].copy() base_im = previous.im.copy()
dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0))
bbox = previous["bbox"] bbox = previous.bbox
if bbox: if bbox:
dispose = dispose.crop(bbox) dispose = dispose.crop(bbox)
else: else:
bbox = (0, 0) + im.size bbox = (0, 0) + im.size
base_im.paste(dispose, bbox) base_im.paste(dispose, bbox)
elif prev_disposal == Disposal.OP_PREVIOUS: elif prev_disposal == Disposal.OP_PREVIOUS:
base_im = im_frames[-2]["im"] base_im = im_frames[-2].im
else: else:
base_im = previous["im"] base_im = previous.im
delta = ImageChops.subtract_modulo( delta = ImageChops.subtract_modulo(
im_frame.convert("RGBA"), base_im.convert("RGBA") im_frame.convert("RGBA"), base_im.convert("RGBA")
) )
@ -1175,18 +1205,18 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
and prev_blend == encoderinfo.get("blend") and prev_blend == encoderinfo.get("blend")
and "duration" in encoderinfo and "duration" in encoderinfo
): ):
previous["encoderinfo"]["duration"] += encoderinfo["duration"] previous.encoderinfo["duration"] += encoderinfo["duration"]
if progress: if progress:
im._save_all_progress(imSequence, i, frame_count, total) im._save_all_progress(imSequence, i, frame_count, total)
continue continue
else: else:
bbox = None bbox = None
im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) im_frames.append(_Frame(im_frame, bbox, encoderinfo))
if progress: if progress:
im._save_all_progress(imSequence, i, frame_count, total) im._save_all_progress(imSequence, i, frame_count, total)
if len(im_frames) == 1 and not default_image: if len(im_frames) == 1 and not default_image:
return im_frames[0]["im"] return im_frames[0].im
# animation control # animation control
chunk( chunk(
@ -1204,14 +1234,14 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
seq_num = 0 seq_num = 0
for frame, frame_data in enumerate(im_frames): for frame, frame_data in enumerate(im_frames):
im_frame = frame_data["im"] im_frame = frame_data.im
if not frame_data["bbox"]: if not frame_data.bbox:
bbox = (0, 0) + im_frame.size bbox = (0, 0) + im_frame.size
else: else:
bbox = frame_data["bbox"] bbox = frame_data.bbox
im_frame = im_frame.crop(bbox) im_frame = im_frame.crop(bbox)
size = im_frame.size size = im_frame.size
encoderinfo = frame_data["encoderinfo"] encoderinfo = frame_data.encoderinfo
frame_duration = int(round(encoderinfo.get("duration", 0))) frame_duration = int(round(encoderinfo.get("duration", 0)))
frame_disposal = encoderinfo.get("disposal", disposal) frame_disposal = encoderinfo.get("disposal", disposal)
frame_blend = encoderinfo.get("blend", blend) frame_blend = encoderinfo.get("blend", blend)
@ -1246,13 +1276,16 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
[("zip", (0, 0) + im_frame.size, 0, rawmode)], [("zip", (0, 0) + im_frame.size, 0, rawmode)],
) )
seq_num = fdat_chunks.seq_num seq_num = fdat_chunks.seq_num
return None
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, save_all=True) _save(im, fp, filename, save_all=True)
def _save(im, fp, filename, chunk=putchunk, save_all=False): def _save(
im: Image.Image, fp, filename: str | bytes, chunk=putchunk, save_all: bool = False
) -> None:
# save an image to disk (called by the save method) # save an image to disk (called by the save method)
if save_all: if save_all:
@ -1428,12 +1461,15 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
exif = exif[6:] exif = exif[6:]
chunk(fp, b"eXIf", exif) chunk(fp, b"eXIf", exif)
single_im: Image.Image | None = im
if save_all: if save_all:
im = _write_multiple_frames( single_im = _write_multiple_frames(
im, fp, chunk, mode, rawmode, default_image, append_images im, fp, chunk, mode, rawmode, default_image, append_images
) )
if im: if single_im:
ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) ImageFile._save(
single_im, _idat(fp, chunk), [("zip", (0, 0) + single_im.size, 0, rawmode)]
)
if info: if info:
for info_chunk in info.chunks: for info_chunk in info.chunks:
@ -1454,7 +1490,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
# PNG chunk converter # PNG chunk converter
def getchunks(im, **params): def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes]]:
"""Return a list of PNG chunks representing this image.""" """Return a list of PNG chunks representing this image."""
class collector: class collector:
@ -1463,19 +1499,19 @@ def getchunks(im, **params):
def write(self, data: bytes) -> None: def write(self, data: bytes) -> None:
pass pass
def append(self, chunk: bytes) -> None: def append(self, chunk: tuple[bytes, bytes, bytes]) -> None:
self.data.append(chunk) self.data.append(chunk)
def append(fp, cid, *data): def append(fp: collector, cid: bytes, *data: bytes) -> None:
data = b"".join(data) byte_data = b"".join(data)
crc = o32(_crc32(data, _crc32(cid))) crc = o32(_crc32(byte_data, _crc32(cid)))
fp.append((cid, data, crc)) fp.append((cid, byte_data, crc))
fp = collector() fp = collector()
try: try:
im.encoderinfo = params im.encoderinfo = params
_save(im, fp, None, append) _save(im, fp, "", append)
finally: finally:
del im.encoderinfo del im.encoderinfo

View File

@ -185,7 +185,7 @@ def _layerinfo(fp, ct_bytes):
# read layerinfo block # read layerinfo block
layers = [] layers = []
def read(size): def read(size: int) -> bytes:
return ImageFile._safe_read(fp, size) return ImageFile._safe_read(fp, size)
ct = si16(read(2)) ct = si16(read(2))

View File

@ -334,12 +334,13 @@ class IFDRational(Rational):
__slots__ = ("_numerator", "_denominator", "_val") __slots__ = ("_numerator", "_denominator", "_val")
def __init__(self, value, denominator=1): def __init__(self, value, denominator: int = 1) -> None:
""" """
:param value: either an integer numerator, a :param value: either an integer numerator, a
float/rational/other number, or an IFDRational float/rational/other number, or an IFDRational
:param denominator: Optional integer denominator :param denominator: Optional integer denominator
""" """
self._val: Fraction | float
if isinstance(value, IFDRational): if isinstance(value, IFDRational):
self._numerator = value.numerator self._numerator = value.numerator
self._denominator = value.denominator self._denominator = value.denominator
@ -636,13 +637,13 @@ class ImageFileDirectory_v2(_IFDv2Base):
val = (val,) val = (val,)
return val return val
def __contains__(self, tag): def __contains__(self, tag: object) -> bool:
return tag in self._tags_v2 or tag in self._tagdata return tag in self._tags_v2 or tag in self._tagdata
def __setitem__(self, tag, value): def __setitem__(self, tag, value) -> None:
self._setitem(tag, value, self.legacy_api) self._setitem(tag, value, self.legacy_api)
def _setitem(self, tag, value, legacy_api): def _setitem(self, tag, value, legacy_api) -> None:
basetypes = (Number, bytes, str) basetypes = (Number, bytes, str)
info = TiffTags.lookup(tag, self.group) info = TiffTags.lookup(tag, self.group)
@ -758,7 +759,7 @@ class ImageFileDirectory_v2(_IFDv2Base):
return data return data
@_register_writer(1) # Basic type, except for the legacy API. @_register_writer(1) # Basic type, except for the legacy API.
def write_byte(self, data): def write_byte(self, data) -> bytes:
if isinstance(data, IFDRational): if isinstance(data, IFDRational):
data = int(data) data = int(data)
if isinstance(data, int): if isinstance(data, int):
@ -772,7 +773,7 @@ class ImageFileDirectory_v2(_IFDv2Base):
return data.decode("latin-1", "replace") return data.decode("latin-1", "replace")
@_register_writer(2) @_register_writer(2)
def write_string(self, value): def write_string(self, value) -> bytes:
# remerge of https://github.com/python-pillow/Pillow/pull/1416 # remerge of https://github.com/python-pillow/Pillow/pull/1416
if isinstance(value, int): if isinstance(value, int):
value = str(value) value = str(value)
@ -790,7 +791,7 @@ class ImageFileDirectory_v2(_IFDv2Base):
return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2]))
@_register_writer(5) @_register_writer(5)
def write_rational(self, *values): def write_rational(self, *values) -> bytes:
return b"".join( return b"".join(
self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values
) )
@ -800,7 +801,7 @@ class ImageFileDirectory_v2(_IFDv2Base):
return data return data
@_register_writer(7) @_register_writer(7)
def write_undefined(self, value): def write_undefined(self, value) -> bytes:
if isinstance(value, IFDRational): if isinstance(value, IFDRational):
value = int(value) value = int(value)
if isinstance(value, int): if isinstance(value, int):
@ -817,13 +818,13 @@ class ImageFileDirectory_v2(_IFDv2Base):
return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2]))
@_register_writer(10) @_register_writer(10)
def write_signed_rational(self, *values): def write_signed_rational(self, *values) -> bytes:
return b"".join( return b"".join(
self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31))) self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31)))
for frac in values for frac in values
) )
def _ensure_read(self, fp, size): def _ensure_read(self, fp: IO[bytes], size: int) -> bytes:
ret = fp.read(size) ret = fp.read(size)
if len(ret) != size: if len(ret) != size:
msg = ( msg = (
@ -977,7 +978,7 @@ class ImageFileDirectory_v2(_IFDv2Base):
return result return result
def save(self, fp): def save(self, fp: IO[bytes]) -> int:
if fp.tell() == 0: # skip TIFF header on subsequent pages if fp.tell() == 0: # skip TIFF header on subsequent pages
# tiff header -- PIL always starts the first IFD at offset 8 # tiff header -- PIL always starts the first IFD at offset 8
fp.write(self._prefix + self._pack("HL", 42, 8)) fp.write(self._prefix + self._pack("HL", 42, 8))
@ -1017,7 +1018,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2):
.. deprecated:: 3.0.0 .. deprecated:: 3.0.0
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._legacy_api = True self._legacy_api = True
@ -1029,7 +1030,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2):
"""Dictionary of tag types""" """Dictionary of tag types"""
@classmethod @classmethod
def from_v2(cls, original): def from_v2(cls, original) -> ImageFileDirectory_v1:
"""Returns an """Returns an
:py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1`
instance with the same data as is contained in the original instance with the same data as is contained in the original
@ -1063,7 +1064,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2):
ifd._tags_v2 = dict(self._tags_v2) ifd._tags_v2 = dict(self._tags_v2)
return ifd return ifd
def __contains__(self, tag): def __contains__(self, tag: object) -> bool:
return tag in self._tags_v1 or tag in self._tagdata return tag in self._tags_v1 or tag in self._tagdata
def __len__(self) -> int: def __len__(self) -> int:
@ -1072,7 +1073,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2):
def __iter__(self): def __iter__(self):
return iter(set(self._tagdata) | set(self._tags_v1)) return iter(set(self._tagdata) | set(self._tags_v1))
def __setitem__(self, tag, value): def __setitem__(self, tag, value) -> None:
for legacy_api in (False, True): for legacy_api in (False, True):
self._setitem(tag, value, legacy_api) self._setitem(tag, value, legacy_api)
@ -1122,7 +1123,7 @@ class TiffImageFile(ImageFile.ImageFile):
self.tag_v2 = ImageFileDirectory_v2(ifh) self.tag_v2 = ImageFileDirectory_v2(ifh)
# legacy IFD entries will be filled in later # legacy IFD entries will be filled in later
self.ifd = None self.ifd: ImageFileDirectory_v1 | None = None
# setup frame pointers # setup frame pointers
self.__first = self.__next = self.tag_v2.next self.__first = self.__next = self.tag_v2.next
@ -1232,7 +1233,7 @@ class TiffImageFile(ImageFile.ImageFile):
val = val[math.ceil((10 + n + size) / 2) * 2 :] val = val[math.ceil((10 + n + size) / 2) * 2 :]
return blocks return blocks
def load(self): def load(self) -> Image.core.PixelAccess | None:
if self.tile and self.use_load_libtiff: if self.tile and self.use_load_libtiff:
return self._load_libtiff() return self._load_libtiff()
return super().load() return super().load()
@ -1343,7 +1344,7 @@ class TiffImageFile(ImageFile.ImageFile):
return Image.Image.load(self) return Image.Image.load(self)
def _setup(self): def _setup(self) -> None:
"""Setup this image object based on current tags""" """Setup this image object based on current tags"""
if 0xBC01 in self.tag_v2: if 0xBC01 in self.tag_v2:
@ -1537,13 +1538,13 @@ class TiffImageFile(ImageFile.ImageFile):
# adjust stride width accordingly # adjust stride width accordingly
stride /= bps_count stride /= bps_count
a = (tile_rawmode, int(stride), 1) args = (tile_rawmode, int(stride), 1)
self.tile.append( self.tile.append(
( (
self._compression, self._compression,
(x, y, min(x + w, xsize), min(y + h, ysize)), (x, y, min(x + w, xsize), min(y + h, ysize)),
offset, offset,
a, args,
) )
) )
x = x + w x = x + w
@ -1938,7 +1939,7 @@ class AppendingTiffWriter:
521, # JPEGACTables 521, # JPEGACTables
} }
def __init__(self, fn, new=False): def __init__(self, fn, new: bool = False) -> None:
if hasattr(fn, "read"): if hasattr(fn, "read"):
self.f = fn self.f = fn
self.close_fp = False self.close_fp = False
@ -2015,7 +2016,7 @@ class AppendingTiffWriter:
def tell(self) -> int: def tell(self) -> int:
return self.f.tell() - self.offsetOfNewPage return self.f.tell() - self.offsetOfNewPage
def seek(self, offset, whence=io.SEEK_SET): def seek(self, offset: int, whence=io.SEEK_SET) -> int:
if whence == os.SEEK_SET: if whence == os.SEEK_SET:
offset += self.offsetOfNewPage offset += self.offsetOfNewPage

View File

@ -24,8 +24,11 @@ and has been tested with a few sample files found using google.
""" """
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
from ._binary import i32le as i32 from ._binary import i32le as i32
from ._typing import StrOrBytesPath
class WalImageFile(ImageFile.ImageFile): class WalImageFile(ImageFile.ImageFile):
@ -50,7 +53,7 @@ class WalImageFile(ImageFile.ImageFile):
if next_name: if next_name:
self.info["next_name"] = next_name self.info["next_name"] = next_name
def load(self): def load(self) -> Image.core.PixelAccess | None:
if not self.im: if not self.im:
self.im = Image.core.new(self.mode, self.size) self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self.size[0] * self.size[1])) self.frombytes(self.fp.read(self.size[0] * self.size[1]))
@ -58,7 +61,7 @@ class WalImageFile(ImageFile.ImageFile):
return Image.Image.load(self) return Image.Image.load(self)
def open(filename): def open(filename: StrOrBytesPath | IO[bytes]) -> WalImageFile:
""" """
Load texture from a Quake2 WAL texture file. Load texture from a Quake2 WAL texture file.

View File

@ -115,7 +115,7 @@ class WebPImageFile(ImageFile.ImageFile):
self.__loaded = -1 self.__loaded = -1
self.__timestamp = 0 self.__timestamp = 0
def _get_next(self): def _get_next(self) -> tuple[bytes, int, int]:
# Get next frame # Get next frame
ret = self._decoder.get_next() ret = self._decoder.get_next()
self.__physical_frame += 1 self.__physical_frame += 1
@ -144,7 +144,7 @@ class WebPImageFile(ImageFile.ImageFile):
while self.__physical_frame < frame: while self.__physical_frame < frame:
self._get_next() # Advance to the requested frame self._get_next() # Advance to the requested frame
def load(self): def load(self) -> Image.core.PixelAccess | None:
if _webp.HAVE_WEBPANIM: if _webp.HAVE_WEBPANIM:
if self.__loaded != self.__logical_frame: if self.__loaded != self.__logical_frame:
self._seek(self.__logical_frame) self._seek(self.__logical_frame)

View File

@ -152,7 +152,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
def _load(self) -> ImageFile.StubHandler | None: def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def load(self, dpi=None): def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None:
if dpi is not None and self._inch is not None: if dpi is not None and self._inch is not None:
self.info["dpi"] = dpi self.info["dpi"] = dpi
x0, y0, x1, y1 = self.info["wmf_bbox"] x0, y0, x1, y1 = self.info["wmf_bbox"]

3
src/PIL/_imagingtk.pyi Normal file
View File

@ -0,0 +1,3 @@
from typing import Any
def __getattr__(name: str) -> Any: ...

View File

@ -2251,6 +2251,11 @@ _getcolors(ImagingObject *self, PyObject *args) {
ImagingColorItem *v = &items[i]; ImagingColorItem *v = &items[i];
PyObject *item = Py_BuildValue( PyObject *item = Py_BuildValue(
"iN", v->count, getpixel(self->image, self->access, v->x, v->y)); "iN", v->count, getpixel(self->image, self->access, v->x, v->y));
if (item == NULL) {
Py_DECREF(out);
free(items);
return NULL;
}
PyList_SetItem(out, i, item); PyList_SetItem(out, i, item);
} }
} }
@ -4448,5 +4453,9 @@ PyInit__imaging(void) {
return NULL; return NULL;
} }
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -1538,5 +1538,9 @@ PyInit__imagingcms(void) {
PyDateTime_IMPORT; PyDateTime_IMPORT;
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -20,6 +20,7 @@
#define PY_SSIZE_T_CLEAN #define PY_SSIZE_T_CLEAN
#include "Python.h" #include "Python.h"
#include "thirdparty/pythoncapi_compat.h"
#include "libImaging/Imaging.h" #include "libImaging/Imaging.h"
#include <ft2build.h> #include <ft2build.h>
@ -1209,30 +1210,49 @@ font_getvarnames(FontObject *self) {
return NULL; return NULL;
} }
int *list_names_filled = PyMem_Malloc(num_namedstyles * sizeof(int));
if (list_names_filled == NULL) {
Py_DECREF(list_names);
FT_Done_MM_Var(library, master);
return PyErr_NoMemory();
}
for (int i = 0; i < num_namedstyles; i++) {
list_names_filled[i] = 0;
}
name_count = FT_Get_Sfnt_Name_Count(self->face); name_count = FT_Get_Sfnt_Name_Count(self->face);
for (i = 0; i < name_count; i++) { for (i = 0; i < name_count; i++) {
error = FT_Get_Sfnt_Name(self->face, i, &name); error = FT_Get_Sfnt_Name(self->face, i, &name);
if (error) { if (error) {
PyMem_Free(list_names_filled);
Py_DECREF(list_names); Py_DECREF(list_names);
FT_Done_MM_Var(library, master); FT_Done_MM_Var(library, master);
return geterror(error); return geterror(error);
} }
for (j = 0; j < num_namedstyles; j++) { for (j = 0; j < num_namedstyles; j++) {
if (PyList_GetItem(list_names, j) != NULL) { if (list_names_filled[j]) {
continue; continue;
} }
if (master->namedstyle[j].strid == name.name_id) { if (master->namedstyle[j].strid == name.name_id) {
list_name = Py_BuildValue("y#", name.string, name.string_len); list_name = Py_BuildValue("y#", name.string, name.string_len);
if (list_name == NULL) {
PyMem_Free(list_names_filled);
Py_DECREF(list_names);
FT_Done_MM_Var(library, master);
return NULL;
}
PyList_SetItem(list_names, j, list_name); PyList_SetItem(list_names, j, list_name);
list_names_filled[j] = 1;
break; break;
} }
} }
} }
PyMem_Free(list_names_filled);
FT_Done_MM_Var(library, master); FT_Done_MM_Var(library, master);
return list_names; return list_names;
} }
@ -1289,9 +1309,14 @@ font_getvaraxes(FontObject *self) {
if (name.name_id == axis.strid) { if (name.name_id == axis.strid) {
axis_name = Py_BuildValue("y#", name.string, name.string_len); axis_name = Py_BuildValue("y#", name.string, name.string_len);
PyDict_SetItemString( if (axis_name == NULL) {
list_axis, "name", axis_name ? axis_name : Py_None); Py_DECREF(list_axis);
Py_XDECREF(axis_name); Py_DECREF(list_axes);
FT_Done_MM_Var(library, master);
return NULL;
}
PyDict_SetItemString(list_axis, "name", axis_name);
Py_DECREF(axis_name);
break; break;
} }
} }
@ -1345,7 +1370,12 @@ font_setvaraxes(FontObject *self, PyObject *args) {
return PyErr_NoMemory(); return PyErr_NoMemory();
} }
for (i = 0; i < num_coords; i++) { for (i = 0; i < num_coords; i++) {
item = PyList_GET_ITEM(axes, i); item = PyList_GetItemRef(axes, i);
if (item == NULL) {
free(coords);
return NULL;
}
if (PyFloat_Check(item)) { if (PyFloat_Check(item)) {
coord = PyFloat_AS_DOUBLE(item); coord = PyFloat_AS_DOUBLE(item);
} else if (PyLong_Check(item)) { } else if (PyLong_Check(item)) {
@ -1353,10 +1383,12 @@ font_setvaraxes(FontObject *self, PyObject *args) {
} else if (PyNumber_Check(item)) { } else if (PyNumber_Check(item)) {
coord = PyFloat_AsDouble(item); coord = PyFloat_AsDouble(item);
} else { } else {
Py_DECREF(item);
free(coords); free(coords);
PyErr_SetString(PyExc_TypeError, "list must contain numbers"); PyErr_SetString(PyExc_TypeError, "list must contain numbers");
return NULL; return NULL;
} }
Py_DECREF(item);
coords[i] = coord * 65536; coords[i] = coord * 65536;
} }
@ -1576,5 +1608,9 @@ PyInit__imagingft(void) {
return NULL; return NULL;
} }
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -290,5 +290,9 @@ PyInit__imagingmath(void) {
return NULL; return NULL;
} }
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -269,5 +269,9 @@ PyInit__imagingmorph(void) {
m = PyModule_Create(&module_def); m = PyModule_Create(&module_def);
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -62,5 +62,10 @@ PyInit__imagingtk(void) {
Py_DECREF(m); Py_DECREF(m);
return NULL; return NULL;
} }
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -1005,5 +1005,9 @@ PyInit__webp(void) {
return NULL; return NULL;
} }
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m; return m;
} }

View File

@ -25,6 +25,7 @@
#define PY_SSIZE_T_CLEAN #define PY_SSIZE_T_CLEAN
#include "Python.h" #include "Python.h"
#include "thirdparty/pythoncapi_compat.h"
#include "libImaging/Imaging.h" #include "libImaging/Imaging.h"
#include "libImaging/Gif.h" #include "libImaging/Gif.h"
@ -671,11 +672,17 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
tags_size = PyList_Size(tags); tags_size = PyList_Size(tags);
TRACE(("tags size: %d\n", (int)tags_size)); TRACE(("tags size: %d\n", (int)tags_size));
for (pos = 0; pos < tags_size; pos++) { for (pos = 0; pos < tags_size; pos++) {
item = PyList_GetItem(tags, pos); item = PyList_GetItemRef(tags, pos);
if (item == NULL) {
return NULL;
}
if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) {
Py_DECREF(item);
PyErr_SetString(PyExc_ValueError, "Invalid tags list"); PyErr_SetString(PyExc_ValueError, "Invalid tags list");
return NULL; return NULL;
} }
Py_DECREF(item);
} }
pos = 0; pos = 0;
} }
@ -703,11 +710,17 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
num_core_tags = sizeof(core_tags) / sizeof(int); num_core_tags = sizeof(core_tags) / sizeof(int);
for (pos = 0; pos < tags_size; pos++) { for (pos = 0; pos < tags_size; pos++) {
item = PyList_GetItem(tags, pos); item = PyList_GetItemRef(tags, pos);
if (item == NULL) {
return NULL;
}
// We already checked that tags is a 2-tuple list. // We already checked that tags is a 2-tuple list.
key = PyTuple_GetItem(item, 0); key = PyTuple_GET_ITEM(item, 0);
key_int = (int)PyLong_AsLong(key); key_int = (int)PyLong_AsLong(key);
value = PyTuple_GetItem(item, 1); value = PyTuple_GET_ITEM(item, 1);
Py_DECREF(item);
status = 0; status = 0;
is_core_tag = 0; is_core_tag = 0;
is_var_length = 0; is_var_length = 0;
@ -721,7 +734,10 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
} }
if (!is_core_tag) { if (!is_core_tag) {
PyObject *tag_type = PyDict_GetItem(types, key); PyObject *tag_type;
if (PyDict_GetItemRef(types, key, &tag_type) < 0) {
return NULL; // Exception has been already set
}
if (tag_type) { if (tag_type) {
int type_int = PyLong_AsLong(tag_type); int type_int = PyLong_AsLong(tag_type);
if (type_int >= TIFF_BYTE && type_int <= TIFF_DOUBLE) { if (type_int >= TIFF_BYTE && type_int <= TIFF_DOUBLE) {

View File

@ -26,6 +26,7 @@
*/ */
#include "Python.h" #include "Python.h"
#include "thirdparty/pythoncapi_compat.h"
#include "libImaging/Imaging.h" #include "libImaging/Imaging.h"
#include <math.h> #include <math.h>
@ -179,14 +180,21 @@ PyPath_Flatten(PyObject *data, double **pxy) {
} \ } \
free(xy); \ free(xy); \
return -1; \ return -1; \
} \
if (decref) { \
Py_DECREF(op); \
} }
/* Copy table to path array */ /* Copy table to path array */
if (PyList_Check(data)) { if (PyList_Check(data)) {
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
double x, y; double x, y;
PyObject *op = PyList_GET_ITEM(data, i); PyObject *op = PyList_GetItemRef(data, i);
assign_item_to_array(op, 0); if (op == NULL) {
free(xy);
return -1;
}
assign_item_to_array(op, 1);
} }
} else if (PyTuple_Check(data)) { } else if (PyTuple_Check(data)) {
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
@ -209,7 +217,6 @@ PyPath_Flatten(PyObject *data, double **pxy) {
} }
} }
assign_item_to_array(op, 1); assign_item_to_array(op, 1);
Py_DECREF(op);
} }
} }

1360
src/thirdparty/pythoncapi_compat.h vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -38,9 +38,11 @@ deps =
ipython ipython
numpy numpy
packaging packaging
pytest
types-defusedxml types-defusedxml
types-olefile types-olefile
types-setuptools
extras = extras =
typing typing
commands = commands =
mypy src {posargs} mypy src Tests {posargs}