Merge branch 'main' into load_default_imagefont

This commit is contained in:
Andrew Murray 2024-06-24 08:04:53 +10:00
commit 2f85bf178b
140 changed files with 1907 additions and 1161 deletions

View File

@ -32,10 +32,10 @@ install:
- curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip - curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip
- 7z x pillow-test-images.zip -oc:\ - 7z x pillow-test-images.zip -oc:\
- xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images
- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip
- 7z x nasm-win64.zip -oc:\ - 7z x nasm-win64.zip -oc:\
- choco install ghostscript --version=10.3.0 - choco install ghostscript --version=10.3.1
- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% - path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH%
- cd c:\pillow\winbuild\ - cd c:\pillow\winbuild\
- ps: | - ps: |
c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\

View File

@ -1 +1 @@
cibuildwheel==2.18.1 cibuildwheel==2.19.1

View File

@ -7,11 +7,15 @@ brew install \
ghostscript \ ghostscript \
libimagequant \ libimagequant \
libjpeg \ libjpeg \
libraqm \
libtiff \ libtiff \
little-cms2 \ little-cms2 \
openjpeg \ openjpeg \
webp webp
if [[ "$ImageOS" == "macos13" ]]; then
brew install --ignore-dependencies libraqm
else
brew install libraqm
fi
export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
# TODO Update condition when cffi supports 3.13 # TODO Update condition when cffi supports 3.13

View File

@ -86,7 +86,7 @@ jobs:
choco install nasm --no-progress choco install nasm --no-progress
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
choco install ghostscript --version=10.3.0 --no-progress choco install ghostscript --version=10.3.1 --no-progress
echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH
# Install extra test images # Install extra test images

View File

@ -16,9 +16,9 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds # Package versions for fresh source builds
FREETYPE_VERSION=2.13.2 FREETYPE_VERSION=2.13.2
HARFBUZZ_VERSION=8.4.0 HARFBUZZ_VERSION=8.5.0
LIBPNG_VERSION=1.6.43 LIBPNG_VERSION=1.6.43
JPEGTURBO_VERSION=3.0.2 JPEGTURBO_VERSION=3.0.3
OPENJPEG_VERSION=2.5.2 OPENJPEG_VERSION=2.5.2
XZ_VERSION=5.4.5 XZ_VERSION=5.4.5
TIFF_VERSION=4.6.0 TIFF_VERSION=4.6.0
@ -33,9 +33,9 @@ if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then
else else
ZLIB_VERSION=1.2.8 ZLIB_VERSION=1.2.8
fi fi
LIBWEBP_VERSION=1.3.2 LIBWEBP_VERSION=1.4.0
BZIP2_VERSION=1.0.8 BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.16.1 LIBXCB_VERSION=1.17.0
BROTLI_VERSION=1.1.0 BROTLI_VERSION=1.1.0
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then
@ -70,7 +70,7 @@ function build {
fi fi
build_new_zlib build_new_zlib
build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto
if [ -n "$IS_MACOS" ]; then if [ -n "$IS_MACOS" ]; then
build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto
build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib

View File

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.3 rev: v0.4.7
hooks: hooks:
- id: ruff - id: ruff
args: [--exit-non-zero-on-fix] args: [--exit-non-zero-on-fix]
@ -24,7 +24,7 @@ repos:
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$)
- repo: https://github.com/pre-commit/mirrors-clang-format - repo: https://github.com/pre-commit/mirrors-clang-format
rev: v18.1.4 rev: v18.1.5
hooks: hooks:
- id: clang-format - id: clang-format
types: [c] types: [c]
@ -50,7 +50,7 @@ repos:
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema - repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.28.2 rev: 0.28.4
hooks: hooks:
- id: check-github-workflows - id: check-github-workflows
- id: check-readthedocs - id: check-readthedocs
@ -67,7 +67,7 @@ repos:
- id: pyproject-fmt - id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject - repo: https://github.com/abravalheri/validate-pyproject
rev: v0.16 rev: v0.18
hooks: hooks:
- id: validate-pyproject - id: validate-pyproject

View File

@ -3,7 +3,7 @@ version: 2
formats: [pdf] formats: [pdf]
build: build:
os: ubuntu-22.04 os: ubuntu-lts-latest
tools: tools:
python: "3" python: "3"
jobs: jobs:

View File

@ -5,6 +5,30 @@ Changelog (Pillow)
10.4.0 (unreleased) 10.4.0 (unreleased)
------------------- -------------------
- Support unpacking more rawmodes to RGBA palettes #7966
[radarhere]
- Removed support for Qt 5 #8159
[radarhere]
- Improve ``ImageFont.freetype`` support for XDG directories on Linux #8135
[mamg22, radarhere]
- Improved consistency of XMP handling #8069
[radarhere]
- Use pkg-config to help find libwebp and raqm #8142
[radarhere]
- Accept 't' suffix for libtiff version #8126, #8129
[radarhere]
- Deprecate ImageDraw.getdraw hints parameter #8124
[radarhere, hugovk]
- Added ImageDraw circle() #8085
[void4, hugovk, radarhere]
- Add mypy target to Makefile #8077 - Add mypy target to Makefile #8077
[Yay295] [Yay295]

View File

@ -44,6 +44,7 @@ def test_direct() -> None:
caccess = im.im.pixel_access(False) caccess = im.im.pixel_access(False)
access = PyAccess.new(im, False) access = PyAccess.new(im, False)
assert access is not None
assert caccess[(0, 0)] == access[(0, 0)] assert caccess[(0, 0)] == access[(0, 0)]
print(f"Size: {im.width}x{im.height}") print(f"Size: {im.width}x{im.height}")

View File

@ -18,7 +18,7 @@ from typing import Any, Callable, Sequence
import pytest import pytest
from packaging.version import parse as parse_version from packaging.version import parse as parse_version
from PIL import Image, ImageMath, features from PIL import Image, ImageFile, ImageMath, features
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -174,12 +174,13 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
def skip_unless_feature_version( def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:
if not features.check(feature): version = features.version(feature)
if version is None:
return pytest.mark.skip(f"{feature} not available") return pytest.mark.skip(f"{feature} not available")
if reason is None: if reason is None:
reason = f"{feature} is older than {required}" reason = f"{feature} is older than {required}"
version_required = parse_version(required) version_required = parse_version(required)
version_available = parse_version(features.version(feature)) version_available = parse_version(version)
return pytest.mark.skipif(version_available < version_required, reason=reason) return pytest.mark.skipif(version_available < version_required, reason=reason)
@ -189,12 +190,13 @@ def mark_if_feature_version(
version_blacklist: str, version_blacklist: str,
reason: str | None = None, reason: str | None = None,
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:
if not features.check(feature): version = features.version(feature)
if version is None:
return pytest.mark.pil_noop_mark() return pytest.mark.pil_noop_mark()
if reason is None: if reason is None:
reason = f"{feature} is {version_blacklist}" reason = f"{feature} is {version_blacklist}"
version_required = parse_version(version_blacklist) version_required = parse_version(version_blacklist)
version_available = parse_version(features.version(feature)) version_available = parse_version(version)
if ( if (
version_available.major == version_required.major version_available.major == version_required.major
and version_available.minor == version_required.minor and version_available.minor == version_required.minor
@ -220,16 +222,11 @@ class PillowLeakTestCase:
from resource import RUSAGE_SELF, getrusage from resource import RUSAGE_SELF, getrusage
mem = getrusage(RUSAGE_SELF).ru_maxrss mem = getrusage(RUSAGE_SELF).ru_maxrss
if sys.platform == "darwin": # man 2 getrusage:
# man 2 getrusage: # ru_maxrss
# ru_maxrss # This is the maximum resident set size utilized
# This is the maximum resident set size utilized (in bytes). # in bytes on macOS, in kilobytes on Linux
return mem / 1024 # Kb return mem / 1024 if sys.platform == "darwin" else mem
# linux
# man 2 getrusage
# ru_maxrss (since Linux 2.6.32)
# This is the maximum resident set size used (in kilobytes).
return mem # Kb
def _test_leak(self, core: Callable[[], None]) -> None: def _test_leak(self, core: Callable[[], None]) -> None:
start_mem = self._get_mem_usage() start_mem = self._get_mem_usage()
@ -243,7 +240,7 @@ class PillowLeakTestCase:
# helpers # helpers
def fromstring(data: bytes) -> Image.Image: def fromstring(data: bytes) -> ImageFile.ImageFile:
return Image.open(BytesIO(data)) return Image.open(BytesIO(data))

View File

@ -12,8 +12,9 @@ from Tests.helper import skip_unless_feature
if sys.platform.startswith("win32"): if sys.platform.startswith("win32"):
pytest.skip("Fuzzer is linux only", allow_module_level=True) pytest.skip("Fuzzer is linux only", allow_module_level=True)
if features.check("libjpeg_turbo"): libjpeg_turbo_version = features.version("libjpeg_turbo")
version = packaging.version.parse(features.version("libjpeg_turbo")) if libjpeg_turbo_version is not None:
version = packaging.version.parse(libjpeg_turbo_version)
if version.major == 2 and version.minor == 0: if version.major == 2 and version.minor == 0:
pytestmark = pytest.mark.valgrind_known_error( pytestmark = pytest.mark.valgrind_known_error(
reason="Known failing with libjpeg_turbo 2.0" reason="Known failing with libjpeg_turbo 2.0"

View File

@ -354,10 +354,10 @@ class TestColorLut3DCoreAPI:
class TestColorLut3DFilter: class TestColorLut3DFilter:
def test_wrong_args(self) -> None: def test_wrong_args(self) -> None:
with pytest.raises(ValueError, match="should be either an integer"): with pytest.raises(ValueError, match="should be either an integer"):
ImageFilter.Color3DLUT("small", [1]) ImageFilter.Color3DLUT("small", [1]) # type: ignore[arg-type]
with pytest.raises(ValueError, match="should be either an integer"): with pytest.raises(ValueError, match="should be either an integer"):
ImageFilter.Color3DLUT((11, 11), [1]) ImageFilter.Color3DLUT((11, 11), [1]) # type: ignore[arg-type]
with pytest.raises(ValueError, match=r"in \[2, 65\] range"): with pytest.raises(ValueError, match=r"in \[2, 65\] range"):
ImageFilter.Color3DLUT((11, 11, 1), [1]) ImageFilter.Color3DLUT((11, 11, 1), [1])

View File

@ -12,7 +12,7 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS
class TestDecompressionBomb: class TestDecompressionBomb:
def teardown_method(self, method) -> None: def teardown_method(self) -> None:
Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT
def test_no_warning_small_file(self) -> None: def test_no_warning_small_file(self) -> None:

View File

@ -30,7 +30,7 @@ def test_version() -> None:
# Check the correctness of the convenience function # Check the correctness of the convenience function
# and the format of version numbers # and the format of version numbers
def test(name: str, function: Callable[[str], bool]) -> None: def test(name: str, function: Callable[[str], str | None]) -> None:
version = features.version(name) version = features.version(name)
if not features.check(name): if not features.check(name):
assert version is None assert version is None
@ -38,7 +38,9 @@ def test_version() -> None:
assert function(name) == version assert function(name) == version
if name != "PIL": if name != "PIL":
if name == "zlib" and version is not None: if name == "zlib" and version is not None:
version = version.replace(".zlib-ng", "") version = re.sub(".zlib-ng$", "", version)
elif name == "libtiff" and version is not None:
version = re.sub("t$", "", version)
assert version is None or re.search(r"\d+(\.\d+)*$", version) assert version is None or re.search(r"\d+(\.\d+)*$", version)
for module in features.modules: for module in features.modules:
@ -67,12 +69,16 @@ def test_webp_anim() -> None:
@skip_unless_feature("libjpeg_turbo") @skip_unless_feature("libjpeg_turbo")
def test_libjpeg_turbo_version() -> None: def test_libjpeg_turbo_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo")) version = features.version("libjpeg_turbo")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@skip_unless_feature("libimagequant") @skip_unless_feature("libimagequant")
def test_libimagequant_version() -> None: def test_libimagequant_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) version = features.version("libimagequant")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@pytest.mark.parametrize("feature", features.modules) @pytest.mark.parametrize("feature", features.modules)
@ -120,7 +126,7 @@ def test_unsupported_module() -> None:
@pytest.mark.parametrize("supported_formats", (True, False)) @pytest.mark.parametrize("supported_formats", (True, False))
def test_pilinfo(supported_formats) -> None: def test_pilinfo(supported_formats: bool) -> None:
buf = io.StringIO() buf = io.StringIO()
features.pilinfo(buf, supported_formats=supported_formats) features.pilinfo(buf, supported_formats=supported_formats)
out = buf.getvalue() out = buf.getvalue()

View File

@ -140,7 +140,7 @@ def test_load_dib() -> None:
(124, "g/pal8v5.bmp"), (124, "g/pal8v5.bmp"),
), ),
) )
def test_dib_header_size(header_size, path): def test_dib_header_size(header_size: int, path: str) -> None:
image_path = "Tests/images/bmp/" + path image_path = "Tests/images/bmp/" + path
with open(image_path, "rb") as fp: with open(image_path, "rb") as fp:
data = fp.read()[14:] data = fp.read()[14:]

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import IO
import pytest import pytest
from PIL import BufrStubImagePlugin, Image from PIL import BufrStubImagePlugin, Image, ImageFile
from .helper import hopper from .helper import hopper
@ -50,20 +51,20 @@ def test_save(tmp_path: Path) -> None:
def test_handler(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None:
class TestHandler: class TestHandler(ImageFile.StubHandler):
opened = False opened = False
loaded = False loaded = False
saved = False saved = False
def open(self, im) -> None: def open(self, im: ImageFile.StubImageFile) -> None:
self.opened = True self.opened = True
def load(self, im): def load(self, im: ImageFile.StubImageFile) -> Image.Image:
self.loaded = True self.loaded = True
im.fp.close() im.fp.close()
return Image.new("RGB", (1, 1)) return Image.new("RGB", (1, 1))
def save(self, im, fp, filename) -> None: def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None:
self.saved = True self.saved = True
handler = TestHandler() handler = TestHandler()

View File

@ -53,6 +53,7 @@ def test_closed_file() -> None:
def test_seek_after_close() -> None: def test_seek_after_close() -> None:
im = Image.open("Tests/images/iss634.gif") im = Image.open("Tests/images/iss634.gif")
assert isinstance(im, GifImagePlugin.GifImageFile)
im.load() im.load()
im.close() im.close()
@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None:
img = img.convert("RGB") img = img.convert("RGB")
tempfile = str(tmp_path / "temp.gif") tempfile = str(tmp_path / "temp.gif")
GifImagePlugin._save_netpbm(img, 0, tempfile) b = BytesIO()
GifImagePlugin._save_netpbm(img, b, tempfile)
with Image.open(tempfile) as reloaded: with Image.open(tempfile) as reloaded:
assert_image_similar(img, reloaded.convert("RGB"), 0) assert_image_similar(img, reloaded.convert("RGB"), 0)
@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None:
img = img.convert("L") img = img.convert("L")
tempfile = str(tmp_path / "temp.gif") tempfile = str(tmp_path / "temp.gif")
GifImagePlugin._save_netpbm(img, 0, tempfile) b = BytesIO()
GifImagePlugin._save_netpbm(img, b, tempfile)
with Image.open(tempfile) as reloaded: with Image.open(tempfile) as reloaded:
assert_image_similar(img, reloaded.convert("L"), 0) assert_image_similar(img, reloaded.convert("L"), 0)
@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None:
assert rgb_img.getpixel((50, 50)) == circle assert rgb_img.getpixel((50, 50)) == circle
# Check that frame transparency wasn't added unnecessarily # Check that frame transparency wasn't added unnecessarily
assert img._frame_transparency is None assert getattr(img, "_frame_transparency") is None
def test_dispose2_diff(tmp_path: Path) -> None: def test_dispose2_diff(tmp_path: Path) -> None:
@ -1252,10 +1255,11 @@ def test_palette_save_L(tmp_path: Path) -> None:
im = hopper("P") im = hopper("P")
im_l = Image.frombytes("L", im.size, im.tobytes()) im_l = Image.frombytes("L", im.size, im.tobytes())
palette = bytes(im.getpalette()) palette = im.getpalette()
assert palette is not None
out = str(tmp_path / "temp.gif") out = str(tmp_path / "temp.gif")
im_l.save(out, palette=palette) im_l.save(out, palette=bytes(palette))
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) assert_image_equal(reloaded.convert("RGB"), im.convert("RGB"))

View File

@ -5,7 +5,7 @@ from typing import IO
import pytest import pytest
from PIL import GribStubImagePlugin, Image from PIL import GribStubImagePlugin, Image, ImageFile
from .helper import hopper from .helper import hopper
@ -51,7 +51,7 @@ def test_save(tmp_path: Path) -> None:
def test_handler(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None:
class TestHandler: class TestHandler(ImageFile.StubHandler):
opened = False opened = False
loaded = False loaded = False
saved = False saved = False

View File

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import IO from typing import IO
import pytest import pytest
from PIL import Hdf5StubImagePlugin, Image from PIL import Hdf5StubImagePlugin, Image, ImageFile
TEST_FILE = "Tests/images/hdf5.h5" TEST_FILE = "Tests/images/hdf5.h5"
@ -41,7 +42,7 @@ def test_load() -> None:
def test_save() -> None: def test_save() -> None:
# Arrange # Arrange
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
dummy_fp = None dummy_fp = BytesIO()
dummy_filename = "dummy.filename" dummy_filename = "dummy.filename"
# Act / Assert: stub cannot save without an implemented handler # Act / Assert: stub cannot save without an implemented handler
@ -52,7 +53,7 @@ def test_save() -> None:
def test_handler(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None:
class TestHandler: class TestHandler(ImageFile.StubHandler):
opened = False opened = False
loaded = False loaded = False
saved = False saved = False

View File

@ -70,7 +70,9 @@ class TestFileJpeg:
def test_sanity(self) -> None: def test_sanity(self) -> None:
# internal version number # internal version number
assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) version = features.version_codec("jpg")
assert version is not None
assert re.search(r"\d+\.\d+$", version)
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
im.load() im.load()
@ -152,7 +154,7 @@ class TestFileJpeg:
assert k > 0.9 assert k > 0.9
def test_rgb(self) -> None: def test_rgb(self) -> None:
def getchannels(im: Image.Image) -> tuple[int, int, int]: def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, int, int]:
return tuple(v[0] for v in im.layer) return tuple(v[0] for v in im.layer)
im = hopper() im = hopper()
@ -169,7 +171,7 @@ class TestFileJpeg:
[TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"],
) )
def test_dpi(self, test_image_path: str) -> None: def test_dpi(self, test_image_path: str) -> None:
def test(xdpi: int, ydpi: int | None = None): def test(xdpi: int, ydpi: int | None = None) -> tuple[int, int] | None:
with Image.open(test_image_path) as im: with Image.open(test_image_path) as im:
im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi)) im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi))
return im.info.get("dpi") return im.info.get("dpi")
@ -441,7 +443,9 @@ class TestFileJpeg:
assert_image(im1, im2.mode, im2.size) assert_image(im1, im2.mode, im2.size)
def test_subsampling(self) -> None: def test_subsampling(self) -> None:
def getsampling(im: Image.Image): def getsampling(
im: JpegImagePlugin.JpegImageFile,
) -> tuple[int, int, int, int, int, int]:
layer = im.layer layer = im.layer
return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] return layer[0][1:3] + layer[1][1:3] + layer[2][1:3]
@ -697,7 +701,7 @@ class TestFileJpeg:
def test_save_cjpeg(self, tmp_path: Path) -> None: def test_save_cjpeg(self, tmp_path: Path) -> None:
with Image.open(TEST_FILE) as img: with Image.open(TEST_FILE) as img:
tempfile = str(tmp_path / "temp.jpg") tempfile = str(tmp_path / "temp.jpg")
JpegImagePlugin._save_cjpeg(img, 0, tempfile) JpegImagePlugin._save_cjpeg(img, BytesIO(), tempfile)
# Default save quality is 75%, so a tiny bit of difference is alright # Default save quality is 75%, so a tiny bit of difference is alright
assert_image_similar_tofile(img, tempfile, 17) assert_image_similar_tofile(img, tempfile, 17)
@ -915,24 +919,25 @@ class TestFileJpeg:
with Image.open("Tests/images/icc-after-SOF.jpg") as im: with Image.open("Tests/images/icc-after-SOF.jpg") as im:
assert im.info["icc_profile"] == b"profile" assert im.info["icc_profile"] == b"profile"
def test_jpeg_magic_number(self) -> None: def test_jpeg_magic_number(self, monkeypatch: pytest.MonkeyPatch) -> None:
size = 4097 size = 4097
buffer = BytesIO(b"\xFF" * size) # Many xFF bytes buffer = BytesIO(b"\xFF" * size) # Many xFF bytes
buffer.max_pos = 0 max_pos = 0
orig_read = buffer.read orig_read = buffer.read
def read(n=-1): def read(n: int | None = -1) -> bytes:
nonlocal max_pos
res = orig_read(n) res = orig_read(n)
buffer.max_pos = max(buffer.max_pos, buffer.tell()) max_pos = max(max_pos, buffer.tell())
return res return res
buffer.read = read monkeypatch.setattr(buffer, "read", read)
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):
with Image.open(buffer): with Image.open(buffer):
pass pass
# Assert the entire file has not been read # Assert the entire file has not been read
assert 0 < buffer.max_pos < size assert 0 < max_pos < size
def test_getxmp(self) -> None: def test_getxmp(self) -> None:
with Image.open("Tests/images/xmp_test.jpg") as im: with Image.open("Tests/images/xmp_test.jpg") as im:
@ -943,6 +948,7 @@ class TestFileJpeg:
): ):
assert im.getxmp() == {} assert im.getxmp() == {}
else: else:
assert "xmp" in im.info
xmp = im.getxmp() xmp = im.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"] description = xmp["xmpmeta"]["RDF"]["Description"]
@ -1027,8 +1033,10 @@ class TestFileJpeg:
def test_repr_jpeg(self) -> None: def test_repr_jpeg(self) -> None:
im = hopper() im = hopper()
b = im._repr_jpeg_()
assert b is not None
with Image.open(BytesIO(im._repr_jpeg_())) as repr_jpeg: with Image.open(BytesIO(b)) as repr_jpeg:
assert repr_jpeg.format == "JPEG" assert repr_jpeg.format == "JPEG"
assert_image_similar(im, repr_jpeg, 17) assert_image_similar(im, repr_jpeg, 17)

View File

@ -48,7 +48,9 @@ def roundtrip(im: Image.Image, **options: Any) -> Image.Image:
def test_sanity() -> None: def test_sanity() -> None:
# Internal version number # Internal version number
assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("jpg_2000")) version = features.version_codec("jpg_2000")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
with Image.open("Tests/images/test-card-lossless.jp2") as im: with Image.open("Tests/images/test-card-lossless.jp2") as im:
px = im.load() px = im.load()
@ -458,7 +460,7 @@ def test_plt_marker() -> None:
out.seek(length - 2, os.SEEK_CUR) out.seek(length - 2, os.SEEK_CUR)
def test_9bit(): def test_9bit() -> None:
with Image.open("Tests/images/9bit.j2k") as im: with Image.open("Tests/images/9bit.j2k") as im:
assert im.mode == "I;16" assert im.mode == "I;16"
assert im.size == (128, 128) assert im.size == (128, 128)

View File

@ -52,7 +52,9 @@ class LibTiffTestCase:
class TestFileLibTiff(LibTiffTestCase): class TestFileLibTiff(LibTiffTestCase):
def test_version(self) -> None: def test_version(self) -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("libtiff")) version = features.version_codec("libtiff")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+t?$", version)
def test_g4_tiff(self, tmp_path: Path) -> None: def test_g4_tiff(self, tmp_path: Path) -> None:
"""Test the ordinary file path load path""" """Test the ordinary file path load path"""
@ -666,7 +668,8 @@ class TestFileLibTiff(LibTiffTestCase):
pilim.save(buffer_io, format="tiff", compression=compression) pilim.save(buffer_io, format="tiff", compression=compression)
buffer_io.seek(0) buffer_io.seek(0)
assert_image_similar_tofile(pilim, buffer_io, 0) with Image.open(buffer_io) as saved_im:
assert_image_similar(pilim, saved_im, 0)
save_bytesio() save_bytesio()
save_bytesio("raw") save_bytesio("raw")

View File

@ -85,9 +85,9 @@ class TestFilePng:
def test_sanity(self, tmp_path: Path) -> None: def test_sanity(self, tmp_path: Path) -> None:
# internal version number # internal version number
assert re.search( version = features.version_codec("zlib")
r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", features.version_codec("zlib") assert version is not None
) assert re.search(r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", version)
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")
@ -535,8 +535,10 @@ class TestFilePng:
def test_repr_png(self) -> None: def test_repr_png(self) -> None:
im = hopper() im = hopper()
b = im._repr_png_()
assert b is not None
with Image.open(BytesIO(im._repr_png_())) as repr_png: with Image.open(BytesIO(b)) as repr_png:
assert repr_png.format == "PNG" assert repr_png.format == "PNG"
assert_image_equal(im, repr_png) assert_image_equal(im, repr_png)
@ -683,6 +685,7 @@ class TestFilePng:
): ):
assert im.getxmp() == {} assert im.getxmp() == {}
else: else:
assert "xmp" in im.info
xmp = im.getxmp() xmp = im.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"] description = xmp["xmpmeta"]["RDF"]["Description"]
@ -767,14 +770,10 @@ class TestFilePng:
def test_save_stdout(self, buffer: bool) -> None: def test_save_stdout(self, buffer: bool) -> None:
old_stdout = sys.stdout old_stdout = sys.stdout
if buffer: class MyStdOut:
buffer = BytesIO()
class MyStdOut: mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
buffer = BytesIO()
mystdout = MyStdOut()
else:
mystdout = BytesIO()
sys.stdout = mystdout sys.stdout = mystdout
@ -784,7 +783,7 @@ class TestFilePng:
# Reset stdout # Reset stdout
sys.stdout = old_stdout sys.stdout = old_stdout
if buffer: if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer mystdout = mystdout.buffer
with Image.open(mystdout) as reloaded: with Image.open(mystdout) as reloaded:
assert_image_equal_tofile(reloaded, TEST_PNG_FILE) assert_image_equal_tofile(reloaded, TEST_PNG_FILE)

View File

@ -368,14 +368,10 @@ def test_mimetypes(tmp_path: Path) -> None:
def test_save_stdout(buffer: bool) -> None: def test_save_stdout(buffer: bool) -> None:
old_stdout = sys.stdout old_stdout = sys.stdout
if buffer: class MyStdOut:
buffer = BytesIO()
class MyStdOut: mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
buffer = BytesIO()
mystdout = MyStdOut()
else:
mystdout = BytesIO()
sys.stdout = mystdout sys.stdout = mystdout
@ -385,7 +381,7 @@ def test_save_stdout(buffer: bool) -> None:
# Reset stdout # Reset stdout
sys.stdout = old_stdout sys.stdout = old_stdout
if buffer: if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer mystdout = mystdout.buffer
with Image.open(mystdout) as reloaded: with Image.open(mystdout) as reloaded:
assert_image_equal_tofile(reloaded, TEST_FILE) assert_image_equal_tofile(reloaded, TEST_FILE)

View File

@ -113,14 +113,14 @@ class TestFileTiff:
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")
im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2)
def test_seek_too_large(self): def test_seek_too_large(self) -> None:
with pytest.raises(ValueError, match="Unable to seek to frame"): with pytest.raises(ValueError, match="Unable to seek to frame"):
Image.open("Tests/images/seek_too_large.tif") Image.open("Tests/images/seek_too_large.tif")
def test_set_legacy_api(self) -> None: def test_set_legacy_api(self) -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd = TiffImagePlugin.ImageFileDirectory_v2()
with pytest.raises(Exception) as e: with pytest.raises(Exception) as e:
ifd.legacy_api = None ifd.legacy_api = False
assert str(e.value) == "Not allowing setting of legacy api" assert str(e.value) == "Not allowing setting of legacy api"
def test_xyres_tiff(self) -> None: def test_xyres_tiff(self) -> None:
@ -759,6 +759,7 @@ class TestFileTiff:
): ):
assert im.getxmp() == {} assert im.getxmp() == {}
else: else:
assert "xmp" in im.info
xmp = im.getxmp() xmp = im.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"] description = xmp["xmpmeta"]["RDF"]["Description"]

View File

@ -49,7 +49,9 @@ class TestFileWebp:
def test_version(self) -> None: def test_version(self) -> None:
_webp.WebPDecoderVersion() _webp.WebPDecoderVersion()
_webp.WebPDecoderBuggyAlpha() _webp.WebPDecoderBuggyAlpha()
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("webp")) version = features.version_module("webp")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_read_rgb(self) -> None: def test_read_rgb(self) -> None:
""" """
@ -196,7 +198,9 @@ class TestFileWebp:
(0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)),
) )
@skip_unless_feature("webp_anim") @skip_unless_feature("webp_anim")
def test_invalid_background(self, background, tmp_path: Path) -> None: def test_invalid_background(
self, background: int | tuple[int, ...], tmp_path: Path
) -> None:
temp_file = str(tmp_path / "temp.webp") temp_file = str(tmp_path / "temp.webp")
im = hopper() im = hopper()
with pytest.raises(OSError): with pytest.raises(OSError):

View File

@ -52,8 +52,9 @@ def test_write_animation_L(tmp_path: Path) -> None:
assert_image_similar(im, orig.convert("RGBA"), 32.9) assert_image_similar(im, orig.convert("RGBA"), 32.9)
if is_big_endian(): if is_big_endian():
webp = parse_version(features.version_module("webp")) version = features.version_module("webp")
if webp < parse_version("1.2.2"): assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1) orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
@ -68,7 +69,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
are visually similar to the originals. are visually similar to the originals.
""" """
def check(temp_file) -> None: def check(temp_file: str) -> None:
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert im.n_frames == 2 assert im.n_frames == 2
@ -78,8 +79,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
# Compare second frame to original # Compare second frame to original
if is_big_endian(): if is_big_endian():
webp = parse_version(features.version_module("webp")) version = features.version_module("webp")
if webp < parse_version("1.2.2"): assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1) im.seek(1)
im.load() im.load()

View File

@ -129,6 +129,7 @@ def test_getxmp() -> None:
): ):
assert im.getxmp() == {} assert im.getxmp() == {}
else: else:
assert "xmp" in im.info
assert ( assert (
im.getxmp()["xmpmeta"]["xmptk"] im.getxmp()["xmpmeta"]["xmptk"]
== "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 " == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 "

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import IO
import pytest import pytest
from PIL import Image, WmfImagePlugin from PIL import Image, ImageFile, WmfImagePlugin
from .helper import assert_image_similar_tofile, hopper from .helper import assert_image_similar_tofile, hopper
@ -34,10 +35,13 @@ def test_load() -> None:
def test_register_handler(tmp_path: Path) -> None: def test_register_handler(tmp_path: Path) -> None:
class TestHandler: class TestHandler(ImageFile.StubHandler):
methodCalled = False methodCalled = False
def save(self, im, fp, filename) -> None: def load(self, im: ImageFile.StubImageFile) -> Image.Image:
return Image.new("RGB", (1, 1))
def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None:
self.methodCalled = True self.methodCalled = True
handler = TestHandler() handler = TestHandler()
@ -70,7 +74,7 @@ def test_load_set_dpi() -> None:
@pytest.mark.parametrize("ext", (".wmf", ".emf")) @pytest.mark.parametrize("ext", (".wmf", ".emf"))
def test_save(ext, tmp_path: Path) -> None: def test_save(ext: str, tmp_path: Path) -> None:
im = hopper() im = hopper()
tmpfile = str(tmp_path / ("temp" + ext)) tmpfile = str(tmp_path / ("temp" + ext))

View File

@ -12,7 +12,7 @@ class TestTTypeFontLeak(PillowLeakTestCase):
iterations = 10 iterations = 10
mem_limit = 4096 # k mem_limit = 4096 # k
def _test_font(self, font: ImageFont.FreeTypeFont) -> None: def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None:
im = Image.new("RGB", (255, 255), "white") im = Image.new("RGB", (255, 255), "white")
draw = ImageDraw.ImageDraw(im) draw = ImageDraw.ImageDraw(im)
self._test_leak( self._test_leak(
@ -34,7 +34,7 @@ class TestDefaultFontLeak(TestTTypeFontLeak):
def test_leak(self) -> None: def test_leak(self) -> None:
if features.check_module("freetype2"): if features.check_module("freetype2"):
ImageFont.core = _util.DeferredError(ImportError) ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
try: try:
default_font = ImageFont.load_default() default_font = ImageFont.load_default()
finally: finally:

View File

@ -25,6 +25,7 @@ from PIL import (
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
assert_image_equal_tofile, assert_image_equal_tofile,
assert_image_similar,
assert_image_similar_tofile, assert_image_similar_tofile,
assert_not_all_same, assert_not_all_same,
hopper, hopper,
@ -99,10 +100,18 @@ class TestImage:
JPGFILE = "Tests/images/hopper.jpg" JPGFILE = "Tests/images/hopper.jpg"
with pytest.raises(TypeError): with pytest.raises(TypeError):
with Image.open(PNGFILE, formats=123): with Image.open(PNGFILE, formats=123): # type: ignore[arg-type]
pass pass
for formats in [["JPEG"], ("JPEG",), ["jpeg"], ["Jpeg"], ["jPeG"], ["JpEg"]]: format_list: list[list[str] | tuple[str, ...]] = [
["JPEG"],
("JPEG",),
["jpeg"],
["Jpeg"],
["jPeG"],
["JpEg"],
]
for formats in format_list:
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):
with Image.open(PNGFILE, formats=formats): with Image.open(PNGFILE, formats=formats):
pass pass
@ -138,12 +147,12 @@ class TestImage:
def test_bad_mode(self) -> None: def test_bad_mode(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
with Image.open("filename", "bad mode"): with Image.open("filename", "bad mode"): # type: ignore[arg-type]
pass pass
def test_stringio(self) -> None: def test_stringio(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
with Image.open(io.StringIO()): with Image.open(io.StringIO()): # type: ignore[arg-type]
pass pass
def test_pathlib(self, tmp_path: Path) -> None: def test_pathlib(self, tmp_path: Path) -> None:
@ -185,7 +194,8 @@ class TestImage:
with tempfile.TemporaryFile() as fp: with tempfile.TemporaryFile() as fp:
im.save(fp, "JPEG") im.save(fp, "JPEG")
fp.seek(0) fp.seek(0)
assert_image_similar_tofile(im, fp, 20) with Image.open(fp) as reloaded:
assert_image_similar(im, reloaded, 20)
def test_unknown_extension(self, tmp_path: Path) -> None: def test_unknown_extension(self, tmp_path: Path) -> None:
im = hopper() im = hopper()
@ -383,13 +393,13 @@ class TestImage:
# errors # errors
with pytest.raises(ValueError): with pytest.raises(ValueError):
source.alpha_composite(over, "invalid source") source.alpha_composite(over, "invalid destination") # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
source.alpha_composite(over, (0, 0), "invalid destination") source.alpha_composite(over, (0, 0), "invalid source") # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
source.alpha_composite(over, 0) source.alpha_composite(over, 0) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
source.alpha_composite(over, (0, 0), 0) source.alpha_composite(over, (0, 0), 0) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
source.alpha_composite(over, (0, 0), (0, -1)) source.alpha_composite(over, (0, 0), (0, -1))
@ -497,9 +507,11 @@ class TestImage:
def test_check_size(self) -> None: def test_check_size(self) -> None:
# Checking that the _check_size function throws value errors when we want it to # Checking that the _check_size function throws value errors when we want it to
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", 0) # not a tuple # not a tuple
Image.new("RGB", 0) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", (0,)) # Tuple too short # tuple too short
Image.new("RGB", (0,)) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", (-1, -1)) # w,h < 0 Image.new("RGB", (-1, -1)) # w,h < 0
@ -897,6 +909,10 @@ class TestImage:
assert tag not in exif.get_ifd(0x8769) assert tag not in exif.get_ifd(0x8769)
assert exif.get_ifd(0xA005) assert exif.get_ifd(0xA005)
def test_empty_xmp(self) -> None:
with Image.open("Tests/images/hopper.gif") as im:
assert im.getxmp() == {}
@pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0)))
def test_zero_tobytes(self, size: tuple[int, int]) -> None: def test_zero_tobytes(self, size: tuple[int, int]) -> None:
im = Image.new("RGB", size) im = Image.new("RGB", size)

View File

@ -259,6 +259,7 @@ class TestCffi(AccessTest):
caccess = im.im.pixel_access(False) caccess = im.im.pixel_access(False)
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
access = PyAccess.new(im, False) access = PyAccess.new(im, False)
assert access is not None
w, h = im.size w, h = im.size
for x in range(0, w, 10): for x in range(0, w, 10):
@ -289,6 +290,7 @@ class TestCffi(AccessTest):
caccess = im.im.pixel_access(False) caccess = im.im.pixel_access(False)
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
access = PyAccess.new(im, False) access = PyAccess.new(im, False)
assert access is not None
w, h = im.size w, h = im.size
for x in range(0, w, 10): for x in range(0, w, 10):
@ -299,6 +301,8 @@ class TestCffi(AccessTest):
# Attempt to set the value on a read-only image # Attempt to set the value on a read-only image
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
access = PyAccess.new(im, True) access = PyAccess.new(im, True)
assert access is not None
with pytest.raises(ValueError): with pytest.raises(ValueError):
access[(0, 0)] = color access[(0, 0)] = color
@ -341,6 +345,8 @@ class TestCffi(AccessTest):
im = Image.new(mode, (1, 1)) im = Image.new(mode, (1, 1))
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
access = PyAccess.new(im, False) access = PyAccess.new(im, False)
assert access is not None
access.putpixel((0, 0), color) access.putpixel((0, 0), color)
if len(color) == 3: if len(color) == 3:

View File

@ -86,8 +86,8 @@ def test_fromarray() -> None:
assert test("RGBX") == ("RGBA", (128, 100), True) assert test("RGBX") == ("RGBA", (128, 100), True)
# Test mode is None with no "typestr" in the array interface # Test mode is None with no "typestr" in the array interface
wrapped = Wrapper(hopper("L"), {"shape": (100, 128)})
with pytest.raises(TypeError): with pytest.raises(TypeError):
wrapped = Wrapper(test("L"), {"shape": (100, 128)})
Image.fromarray(wrapped) Image.fromarray(wrapped)

View File

@ -18,7 +18,7 @@ def test_crop(mode: str) -> None:
def test_wide_crop() -> None: def test_wide_crop() -> None:
def crop(*bbox: int) -> tuple[int, ...]: def crop(bbox: tuple[int, int, int, int]) -> tuple[int, ...]:
i = im.crop(bbox) i = im.crop(bbox)
h = i.histogram() h = i.histogram()
while h and not h[-1]: while h and not h[-1]:
@ -27,23 +27,23 @@ def test_wide_crop() -> None:
im = Image.new("L", (100, 100), 1) im = Image.new("L", (100, 100), 1)
assert crop(0, 0, 100, 100) == (0, 10000) assert crop((0, 0, 100, 100)) == (0, 10000)
assert crop(25, 25, 75, 75) == (0, 2500) assert crop((25, 25, 75, 75)) == (0, 2500)
# sides # sides
assert crop(-25, 0, 25, 50) == (1250, 1250) assert crop((-25, 0, 25, 50)) == (1250, 1250)
assert crop(0, -25, 50, 25) == (1250, 1250) assert crop((0, -25, 50, 25)) == (1250, 1250)
assert crop(75, 0, 125, 50) == (1250, 1250) assert crop((75, 0, 125, 50)) == (1250, 1250)
assert crop(0, 75, 50, 125) == (1250, 1250) assert crop((0, 75, 50, 125)) == (1250, 1250)
assert crop(-25, 25, 125, 75) == (2500, 5000) assert crop((-25, 25, 125, 75)) == (2500, 5000)
assert crop(25, -25, 75, 125) == (2500, 5000) assert crop((25, -25, 75, 125)) == (2500, 5000)
# corners # corners
assert crop(-25, -25, 25, 25) == (1875, 625) assert crop((-25, -25, 25, 25)) == (1875, 625)
assert crop(75, -25, 125, 25) == (1875, 625) assert crop((75, -25, 125, 25)) == (1875, 625)
assert crop(75, 75, 125, 125) == (1875, 625) assert crop((75, 75, 125, 125)) == (1875, 625)
assert crop(-25, 75, 25, 125) == (1875, 625) assert crop((-25, 75, 25, 125)) == (1875, 625)
@pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2))) @pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2)))

View File

@ -16,7 +16,9 @@ def draft_roundtrip(
im = Image.new(in_mode, in_size) im = Image.new(in_mode, in_size)
data = tostring(im, "JPEG") data = tostring(im, "JPEG")
im = fromstring(data) im = fromstring(data)
mode, box = im.draft(req_mode, req_size) result = im.draft(req_mode, req_size)
assert result is not None
box = result[1]
scale, _ = im.decoderconfig scale, _ = im.decoderconfig
assert box[:2] == (0, 0) assert box[:2] == (0, 0)
assert (im.width - scale) < box[2] <= im.width assert (im.width - scale) < box[2] <= im.width

View File

@ -46,9 +46,9 @@ def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None:
@pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK"))
def test_sanity_error(mode: str) -> None: def test_sanity_error(mode: str) -> None:
im = hopper(mode)
with pytest.raises(TypeError): with pytest.raises(TypeError):
im = hopper(mode) im.filter("hello") # type: ignore[arg-type]
im.filter("hello")
# crashes on small images # crashes on small images
@ -137,7 +137,7 @@ def test_builtinfilter_p() -> None:
builtin_filter = ImageFilter.BuiltinFilter() builtin_filter = ImageFilter.BuiltinFilter()
with pytest.raises(ValueError): with pytest.raises(ValueError):
builtin_filter.filter(hopper("P")) builtin_filter.filter(hopper("P").im)
def test_kernel_not_enough_coefficients() -> None: def test_kernel_not_enough_coefficients() -> None:

View File

@ -6,7 +6,7 @@ from .helper import hopper
def test_extrema() -> None: def test_extrema() -> None:
def extrema(mode: str) -> tuple[int, int] | tuple[tuple[int, int], ...]: def extrema(mode: str) -> tuple[float, float] | tuple[tuple[int, int], ...]:
return hopper(mode).getextrema() return hopper(mode).getextrema()
assert extrema("1") == (0, 255) assert extrema("1") == (0, 255)

View File

@ -68,7 +68,11 @@ def test_sanity() -> None:
), ),
) )
def test_properties( def test_properties(
mode, expected_base, expected_type, expected_bands, expected_band_names mode: str,
expected_base: str,
expected_type: str,
expected_bands: int,
expected_band_names: tuple[str, ...],
) -> None: ) -> None:
assert Image.getmodebase(mode) == expected_base assert Image.getmodebase(mode) == expected_base
assert Image.getmodetype(mode) == expected_type assert Image.getmodetype(mode) == expected_type

View File

@ -338,3 +338,8 @@ class TestImagingPaste:
im.copy().paste(im2) im.copy().paste(im2)
im.copy().paste(im2, (0, 0)) im.copy().paste(im2, (0, 0))
def test_incorrect_abbreviated_form(self) -> None:
im = Image.new("L", (1, 1))
with pytest.raises(ValueError):
im.paste(im, im, im)

View File

@ -61,4 +61,4 @@ def test_f_lut() -> None:
def test_f_mode() -> None: def test_f_mode() -> None:
im = hopper("F") im = hopper("F")
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.point(None) im.point([])

View File

@ -79,6 +79,7 @@ def test_putpalette_with_alpha_values() -> None:
( (
("RGBA", (1, 2, 3, 4)), ("RGBA", (1, 2, 3, 4)),
("RGBAX", (1, 2, 3, 4, 0)), ("RGBAX", (1, 2, 3, 4, 0)),
("ARGB", (4, 1, 2, 3)),
), ),
) )
def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None:

View File

@ -24,8 +24,9 @@ def test_sanity() -> None:
def test_libimagequant_quantize() -> None: def test_libimagequant_quantize() -> None:
image = hopper() image = hopper()
if is_ppc64le(): if is_ppc64le():
libimagequant = parse_version(features.version_feature("libimagequant")) version = features.version_feature("libimagequant")
if libimagequant < parse_version("4"): assert version is not None
if parse_version(version) < parse_version("4"):
pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le") pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P" assert converted.mode == "P"
@ -97,7 +98,7 @@ def test_quantize_dither_diff() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE) "method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE)
) )
def test_quantize_kmeans(method) -> None: def test_quantize_kmeans(method: Image.Quantize) -> None:
im = hopper() im = hopper()
no_kmeans = im.quantize(kmeans=0, method=method) no_kmeans = im.quantize(kmeans=0, method=method)
kmeans = im.quantize(kmeans=1, method=method) kmeans = im.quantize(kmeans=1, method=method)

View File

@ -56,10 +56,12 @@ def test_args_factor(size: int | tuple[int, int], expected: tuple[int, int]) ->
@pytest.mark.parametrize( @pytest.mark.parametrize(
"size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError))
) )
def test_args_factor_error(size: float | tuple[int, int], expected_error) -> None: def test_args_factor_error(
size: float | tuple[int, int], expected_error: type[Exception]
) -> None:
im = Image.new("L", (10, 10)) im = Image.new("L", (10, 10))
with pytest.raises(expected_error): with pytest.raises(expected_error):
im.reduce(size) im.reduce(size) # type: ignore[arg-type]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -86,10 +88,12 @@ def test_args_box(size: tuple[int, int, int, int], expected: tuple[int, int]) ->
((5, 0, 5, 10), ValueError), ((5, 0, 5, 10), ValueError),
), ),
) )
def test_args_box_error(size: str | tuple[int, int, int, int], expected_error) -> None: def test_args_box_error(
size: str | tuple[int, int, int, int], expected_error: type[Exception]
) -> None:
im = Image.new("L", (10, 10)) im = Image.new("L", (10, 10))
with pytest.raises(expected_error): with pytest.raises(expected_error):
im.reduce(2, size).size im.reduce(2, size).size # type: ignore[arg-type]
@pytest.mark.parametrize("mode", ("P", "1", "I;16")) @pytest.mark.parametrize("mode", ("P", "1", "I;16"))
@ -102,7 +106,7 @@ def test_unsupported_modes(mode: str) -> None:
def get_image(mode: str) -> Image.Image: def get_image(mode: str) -> Image.Image:
mode_info = ImageMode.getmode(mode) mode_info = ImageMode.getmode(mode)
if mode_info.basetype == "L": if mode_info.basetype == "L":
bands = [gradients_image] bands: list[Image.Image] = [gradients_image]
for _ in mode_info.bands[1:]: for _ in mode_info.bands[1:]:
# rotate previous image # rotate previous image
band = bands[-1].transpose(Image.Transpose.ROTATE_90) band = bands[-1].transpose(Image.Transpose.ROTATE_90)

View File

@ -445,7 +445,7 @@ class TestCoreResampleBox:
im.resize((32, 32), resample, (20, 20, 100, 20)) im.resize((32, 32), resample, (20, 20, 100, 20))
with pytest.raises(TypeError, match="must be sequence of length 4"): with pytest.raises(TypeError, match="must be sequence of length 4"):
im.resize((32, 32), resample, (im.width, im.height)) im.resize((32, 32), resample, (im.width, im.height)) # type: ignore[arg-type]
with pytest.raises(ValueError, match="can't be negative"): with pytest.raises(ValueError, match="can't be negative"):
im.resize((32, 32), resample, (-20, 20, 100, 100)) im.resize((32, 32), resample, (-20, 20, 100, 100))

View File

@ -124,8 +124,8 @@ def test_fastpath_translate() -> None:
def test_center() -> None: def test_center() -> None:
im = hopper() im = hopper()
rotate(im, im.mode, 45, center=(0, 0)) rotate(im, im.mode, 45, center=(0, 0))
rotate(im, im.mode, 45, translate=(im.size[0] / 2, 0)) rotate(im, im.mode, 45, translate=(im.size[0] // 2, 0))
rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] / 2, 0)) rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] // 2, 0))
def test_rotate_no_fill() -> None: def test_rotate_no_fill() -> None:

View File

@ -16,7 +16,7 @@ from .helper import (
def test_sanity() -> None: def test_sanity() -> None:
im = hopper() im = hopper()
assert im.thumbnail((100, 100)) is None im.thumbnail((100, 100))
assert im.size == (100, 100) assert im.size == (100, 100)
@ -111,7 +111,9 @@ def test_load_first_unless_jpeg() -> None:
with Image.open("Tests/images/hopper.jpg") as im: with Image.open("Tests/images/hopper.jpg") as im:
draft = im.draft draft = im.draft
def im_draft(mode: str, size: tuple[int, int]): def im_draft(
mode: str, size: tuple[int, int]
) -> tuple[str, tuple[int, int, float, float]] | None:
result = draft(mode, size) result = draft(mode, size)
assert result is not None assert result is not None

View File

@ -7,7 +7,7 @@ import shutil
import sys import sys
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal, cast
import pytest import pytest
@ -60,10 +60,13 @@ def test_sanity() -> None:
assert list(map(type, v)) == [str, str, str, str] assert list(map(type, v)) == [str, str, str, str]
# internal version number # internal version number
assert re.search(r"\d+\.\d+(\.\d+)?$", features.version_module("littlecms2")) version = features.version_module("littlecms2")
assert version is not None
assert re.search(r"\d+\.\d+(\.\d+)?$", version)
skip_missing() skip_missing()
i = ImageCms.profileToProfile(hopper(), SRGB, SRGB) i = ImageCms.profileToProfile(hopper(), SRGB, SRGB)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
i = hopper() i = hopper()
@ -72,23 +75,27 @@ def test_sanity() -> None:
t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
with hopper() as i: with hopper() as i:
t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB")
ImageCms.applyTransform(hopper(), t, inPlace=True) ImageCms.applyTransform(hopper(), t, inPlace=True)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
p = ImageCms.createProfile("sRGB") p = ImageCms.createProfile("sRGB")
o = ImageCms.getOpenProfile(SRGB) o = ImageCms.getOpenProfile(SRGB)
t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB") t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB")
assert t.inputMode == "RGB" assert t.inputMode == "RGB"
assert t.outputMode == "RGB" assert t.outputMode == "RGB"
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
# test PointTransform convenience API # test PointTransform convenience API
@ -96,7 +103,7 @@ def test_sanity() -> None:
def test_flags() -> None: def test_flags() -> None:
assert ImageCms.Flags.NONE == 0 assert ImageCms.Flags.NONE.value == 0
assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE
assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE
@ -202,13 +209,13 @@ def test_exceptions() -> None:
ImageCms.buildTransform("foo", "bar", "RGB", "RGB") ImageCms.buildTransform("foo", "bar", "RGB", "RGB")
with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"): with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"):
ImageCms.getProfileName(None) ImageCms.getProfileName(None) # type: ignore[arg-type]
skip_missing() skip_missing()
# Python <= 3.9: "an integer is required (got type NoneType)" # Python <= 3.9: "an integer is required (got type NoneType)"
# Python > 3.9: "'NoneType' object cannot be interpreted as an integer" # Python > 3.9: "'NoneType' object cannot be interpreted as an integer"
with pytest.raises(ImageCms.PyCMSError, match="integer"): with pytest.raises(ImageCms.PyCMSError, match="integer"):
ImageCms.isIntentSupported(SRGB, None, None) ImageCms.isIntentSupported(SRGB, None, None) # type: ignore[arg-type]
def test_display_profile() -> None: def test_display_profile() -> None:
@ -232,7 +239,7 @@ def test_unsupported_color_space() -> None:
"Color space not supported for on-the-fly profile creation (unsupported)" "Color space not supported for on-the-fly profile creation (unsupported)"
), ),
): ):
ImageCms.createProfile("unsupported") ImageCms.createProfile("unsupported") # type: ignore[arg-type]
def test_invalid_color_temperature() -> None: def test_invalid_color_temperature() -> None:
@ -240,7 +247,7 @@ def test_invalid_color_temperature() -> None:
ImageCms.PyCMSError, ImageCms.PyCMSError,
match='Color temperature must be numeric, "invalid" not valid', match='Color temperature must be numeric, "invalid" not valid',
): ):
ImageCms.createProfile("LAB", "invalid") ImageCms.createProfile("LAB", "invalid") # type: ignore[arg-type]
@pytest.mark.parametrize("flag", ("my string", -1)) @pytest.mark.parametrize("flag", ("my string", -1))
@ -249,7 +256,7 @@ def test_invalid_flag(flag: str | int) -> None:
with pytest.raises( with pytest.raises(
ImageCms.PyCMSError, match="flags must be an integer between 0 and " ImageCms.PyCMSError, match="flags must be an integer between 0 and "
): ):
ImageCms.profileToProfile(im, "foo", "bar", flags=flag) ImageCms.profileToProfile(im, "foo", "bar", flags=flag) # type: ignore[arg-type]
def test_simple_lab() -> None: def test_simple_lab() -> None:
@ -260,7 +267,7 @@ def test_simple_lab() -> None:
t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB") t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB")
i_lab = ImageCms.applyTransform(i, t) i_lab = ImageCms.applyTransform(i, t)
assert i_lab is not None
assert i_lab.mode == "LAB" assert i_lab.mode == "LAB"
k = i_lab.getpixel((0, 0)) k = i_lab.getpixel((0, 0))
@ -284,6 +291,7 @@ def test_lab_color() -> None:
# Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and # Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and
# have that mapping work back to a PIL mode (likely RGB). # have that mapping work back to a PIL mode (likely RGB).
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "LAB", (128, 128)) assert_image(i, "LAB", (128, 128))
# i.save('temp.lab.tif') # visually verified vs PS. # i.save('temp.lab.tif') # visually verified vs PS.
@ -298,6 +306,7 @@ def test_lab_srgb() -> None:
with Image.open("Tests/images/hopper.Lab.tif") as img: with Image.open("Tests/images/hopper.Lab.tif") as img:
img_srgb = ImageCms.applyTransform(img, t) img_srgb = ImageCms.applyTransform(img, t)
assert img_srgb is not None
# img_srgb.save('temp.srgb.tif') # visually verified vs ps. # img_srgb.save('temp.srgb.tif') # visually verified vs ps.
@ -317,11 +326,11 @@ def test_lab_roundtrip() -> None:
t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB") t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes() assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes()
out = ImageCms.applyTransform(i, t2) out = ImageCms.applyTransform(i, t2)
assert out is not None
assert_image_similar(hopper(), out, 2) assert_image_similar(hopper(), out, 2)
@ -343,7 +352,7 @@ def test_extended_information() -> None:
p = o.profile p = o.profile
def assert_truncated_tuple_equal( def assert_truncated_tuple_equal(
tup1: tuple[Any, ...], tup2: tuple[Any, ...], digits: int = 10 tup1: tuple[Any, ...] | None, tup2: tuple[Any, ...], digits: int = 10
) -> None: ) -> None:
# Helper function to reduce precision of tuples of floats # Helper function to reduce precision of tuples of floats
# recursively and then check equality. # recursively and then check equality.
@ -359,6 +368,7 @@ def test_extended_information() -> None:
for val in tuple_value for val in tuple_value
) )
assert tup1 is not None
assert truncate_tuple(tup1) == truncate_tuple(tup2) assert truncate_tuple(tup1) == truncate_tuple(tup2)
assert p.attributes == 4294967296 assert p.attributes == 4294967296
@ -504,22 +514,22 @@ def test_non_ascii_path(tmp_path: Path) -> None:
def test_profile_typesafety() -> None: def test_profile_typesafety() -> None:
# does not segfault # does not segfault
with pytest.raises(TypeError, match="Invalid type for Profile"): with pytest.raises(TypeError, match="Invalid type for Profile"):
ImageCms.ImageCmsProfile(0).tobytes() ImageCms.ImageCmsProfile(0) # type: ignore[arg-type]
with pytest.raises(TypeError, match="Invalid type for Profile"): with pytest.raises(TypeError, match="Invalid type for Profile"):
ImageCms.ImageCmsProfile(1).tobytes() ImageCms.ImageCmsProfile(1) # type: ignore[arg-type]
# also check core function # also check core function
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.profile_tobytes(0) ImageCms.core.profile_tobytes(0) # type: ignore[arg-type]
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.profile_tobytes(1) ImageCms.core.profile_tobytes(1) # type: ignore[arg-type]
if not is_pypy(): if not is_pypy():
# core profile should not be directly instantiable # core profile should not be directly instantiable
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsProfile() ImageCms.core.CmsProfile()
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsProfile(0) ImageCms.core.CmsProfile(0) # type: ignore[call-arg]
@pytest.mark.skipif(is_pypy(), reason="fails on PyPy") @pytest.mark.skipif(is_pypy(), reason="fails on PyPy")
@ -528,7 +538,7 @@ def test_transform_typesafety() -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsTransform() ImageCms.core.CmsTransform()
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsTransform(0) ImageCms.core.CmsTransform(0) # type: ignore[call-arg]
def assert_aux_channel_preserved( def assert_aux_channel_preserved(
@ -559,9 +569,9 @@ def assert_aux_channel_preserved(
for delta in nine_grid_deltas: for delta in nine_grid_deltas:
channel_data.paste( channel_data.paste(
channel_pattern, channel_pattern,
tuple( (
paste_offset[c] + delta[c] * channel_pattern.size[c] paste_offset[0] + delta[0] * channel_pattern.size[0],
for c in range(2) paste_offset[1] + delta[1] * channel_pattern.size[1],
), ),
) )
chans.append(channel_data) chans.append(channel_data)
@ -578,11 +588,13 @@ def assert_aux_channel_preserved(
) )
# apply transform # apply transform
result_image: Image.Image | None
if transform_in_place: if transform_in_place:
ImageCms.applyTransform(source_image, t, inPlace=True) ImageCms.applyTransform(source_image, t, inPlace=True)
result_image = source_image result_image = source_image
else: else:
result_image = ImageCms.applyTransform(source_image, t, inPlace=False) result_image = ImageCms.applyTransform(source_image, t, inPlace=False)
assert result_image is not None
result_image_aux = result_image.getchannel(preserved_channel) result_image_aux = result_image.getchannel(preserved_channel)
assert_image_equal(source_image_aux, result_image_aux) assert_image_equal(source_image_aux, result_image_aux)
@ -628,8 +640,10 @@ def test_auxiliary_channels_isolated() -> None:
continue continue
# convert with and without AUX data, test colors are equal # convert with and without AUX data, test colors are equal
source_profile = ImageCms.createProfile(src_format[1]) src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1])
destination_profile = ImageCms.createProfile(dst_format[1]) source_profile = ImageCms.createProfile(src_colorSpace)
dst_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], dst_format[1])
destination_profile = ImageCms.createProfile(dst_colorSpace)
source_image = src_format[3] source_image = src_format[3]
test_transform = ImageCms.buildTransform( test_transform = ImageCms.buildTransform(
source_profile, source_profile,
@ -639,6 +653,7 @@ def test_auxiliary_channels_isolated() -> None:
) )
# test conversion from aux-ful source # test conversion from aux-ful source
test_image: Image.Image | None
if transform_in_place: if transform_in_place:
test_image = source_image.copy() test_image = source_image.copy()
ImageCms.applyTransform(test_image, test_transform, inPlace=True) ImageCms.applyTransform(test_image, test_transform, inPlace=True)
@ -646,6 +661,7 @@ def test_auxiliary_channels_isolated() -> None:
test_image = ImageCms.applyTransform( test_image = ImageCms.applyTransform(
source_image, test_transform, inPlace=False source_image, test_transform, inPlace=False
) )
assert test_image is not None
# reference conversion from aux-less source # reference conversion from aux-less source
reference_transform = ImageCms.buildTransform( reference_transform = ImageCms.buildTransform(
@ -657,7 +673,7 @@ def test_auxiliary_channels_isolated() -> None:
reference_image = ImageCms.applyTransform( reference_image = ImageCms.applyTransform(
source_image.convert(src_format[2]), reference_transform source_image.convert(src_format[2]), reference_transform
) )
assert reference_image is not None
assert_image_equal(test_image.convert(dst_format[2]), reference_image) assert_image_equal(test_image.convert(dst_format[2]), reference_image)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import contextlib import contextlib
import os.path import os.path
from typing import Sequence
import pytest import pytest
@ -265,6 +266,21 @@ def test_chord_too_fat() -> None:
assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png") assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png")
@pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("xy", ((W / 2, H / 2), [W / 2, H / 2]))
def test_circle(mode: str, xy: Sequence[float]) -> None:
# Arrange
im = Image.new(mode, (W, H))
draw = ImageDraw.Draw(im)
expected = f"Tests/images/imagedraw_ellipse_{mode}.png"
# Act
draw.circle(xy, 25, fill="green", outline="blue")
# Assert
assert_image_similar_tofile(im, expected, 1)
@pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("bbox", BBOX)
def test_ellipse(mode: str, bbox: Coords) -> None: def test_ellipse(mode: str, bbox: Coords) -> None:
@ -432,6 +448,7 @@ def test_shape1() -> None:
x3, y3 = 95, 5 x3, y3 = 95, 5
# Act # Act
assert ImageDraw.Outline is not None
s = ImageDraw.Outline() s = ImageDraw.Outline()
s.move(x0, y0) s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3) s.curve(x1, y1, x2, y2, x3, y3)
@ -453,6 +470,7 @@ def test_shape2() -> None:
x3, y3 = 5, 95 x3, y3 = 5, 95
# Act # Act
assert ImageDraw.Outline is not None
s = ImageDraw.Outline() s = ImageDraw.Outline()
s.move(x0, y0) s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3) s.curve(x1, y1, x2, y2, x3, y3)
@ -471,6 +489,7 @@ def test_transform() -> None:
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
# Act # Act
assert ImageDraw.Outline is not None
s = ImageDraw.Outline() s = ImageDraw.Outline()
s.line(0, 0) s.line(0, 0)
s.transform((0, 0, 0, 0, 0, 0)) s.transform((0, 0, 0, 0, 0, 0))
@ -897,7 +916,12 @@ def test_rounded_rectangle_translucent(
def test_floodfill(bbox: Coords) -> None: def test_floodfill(bbox: Coords) -> None:
red = ImageColor.getrgb("red") red = ImageColor.getrgb("red")
for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: mode_values: list[tuple[str, int | tuple[int, ...]]] = [
("L", 1),
("RGBA", (255, 0, 0, 0)),
("RGB", red),
]
for mode, value in mode_values:
# Arrange # Arrange
im = Image.new(mode, (W, H)) im = Image.new(mode, (W, H))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
@ -1067,8 +1091,8 @@ def test_line_horizontal() -> None:
) )
@pytest.mark.xfail(reason="failing test")
def test_line_h_s1_w2() -> None: def test_line_h_s1_w2() -> None:
pytest.skip("failing")
img, draw = create_base_image_draw((20, 20)) img, draw = create_base_image_draw((20, 20))
draw.line((5, 5, 14, 6), BLACK, 2) draw.line((5, 5, 14, 6), BLACK, 2)
assert_image_equal_tofile( assert_image_equal_tofile(
@ -1413,6 +1437,7 @@ def test_same_color_outline(bbox: Coords) -> None:
x2, y2 = 95, 50 x2, y2 = 95, 50
x3, y3 = 95, 5 x3, y3 = 95, 5
assert ImageDraw.Outline is not None
s = ImageDraw.Outline() s = ImageDraw.Outline()
s.move(x0, y0) s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3) s.curve(x1, y1, x2, y2, x3, y3)
@ -1451,7 +1476,7 @@ def test_same_color_outline(bbox: Coords) -> None:
(4, "square", {}), (4, "square", {}),
(8, "regular_octagon", {}), (8, "regular_octagon", {}),
(4, "square_rotate_45", {"rotation": 45}), (4, "square_rotate_45", {"rotation": 45}),
(3, "triangle_width", {"width": 5, "outline": "yellow"}), (3, "triangle_width", {"outline": "yellow", "width": 5}),
], ],
) )
def test_draw_regular_polygon( def test_draw_regular_polygon(
@ -1461,7 +1486,10 @@ def test_draw_regular_polygon(
filename = f"Tests/images/imagedraw_{polygon_name}.png" filename = f"Tests/images/imagedraw_{polygon_name}.png"
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
bounding_circle = ((W // 2, H // 2), 25) bounding_circle = ((W // 2, H // 2), 25)
draw.regular_polygon(bounding_circle, n_sides, fill="red", **args) rotation = int(args.get("rotation", 0))
outline = args.get("outline")
width = int(args.get("width", 1))
draw.regular_polygon(bounding_circle, n_sides, rotation, "red", outline, width)
assert_image_equal_tofile(im, filename) assert_image_equal_tofile(im, filename)
@ -1546,10 +1574,14 @@ def test_compute_regular_polygon_vertices(
], ],
) )
def test_compute_regular_polygon_vertices_input_error_handling( def test_compute_regular_polygon_vertices_input_error_handling(
n_sides, bounding_circle, rotation, expected_error, error_message n_sides: int,
bounding_circle: int | tuple[int | tuple[int] | str, ...],
rotation: int | str,
expected_error: type[Exception],
error_message: str,
) -> None: ) -> None:
with pytest.raises(expected_error) as e: with pytest.raises(expected_error) as e:
ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type]
assert str(e.value) == error_message assert str(e.value) == error_message
@ -1608,3 +1640,8 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None:
draw.rectangle(xy) draw.rectangle(xy)
with pytest.raises(ValueError): with pytest.raises(ValueError):
draw.rounded_rectangle(xy) draw.rounded_rectangle(xy)
def test_getdraw() -> None:
with pytest.warns(DeprecationWarning):
ImageDraw.getdraw(None, [])

View File

@ -51,9 +51,10 @@ def test_sanity() -> None:
pen = ImageDraw2.Pen("blue", width=7) pen = ImageDraw2.Pen("blue", width=7)
draw.line(list(range(10)), pen) draw.line(list(range(10)), pen)
draw, handler = ImageDraw.getdraw(im) draw2, handler = ImageDraw.getdraw(im)
assert draw2 is not None
pen = ImageDraw2.Pen("blue", width=7) pen = ImageDraw2.Pen("blue", width=7)
draw.line(list(range(10)), pen) draw2.line(list(range(10)), pen)
@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("bbox", BBOX)

View File

@ -202,6 +202,8 @@ class TestImageFile:
class MockPyDecoder(ImageFile.PyDecoder): class MockPyDecoder(ImageFile.PyDecoder):
last: MockPyDecoder
def __init__(self, mode: str, *args: Any) -> None: def __init__(self, mode: str, *args: Any) -> None:
MockPyDecoder.last = self MockPyDecoder.last = self
@ -213,6 +215,8 @@ class MockPyDecoder(ImageFile.PyDecoder):
class MockPyEncoder(ImageFile.PyEncoder): class MockPyEncoder(ImageFile.PyEncoder):
last: MockPyEncoder | None
def __init__(self, mode: str, *args: Any) -> None: def __init__(self, mode: str, *args: Any) -> None:
MockPyEncoder.last = self MockPyEncoder.last = self
@ -315,6 +319,7 @@ class TestPyEncoder(CodecsTest):
im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")]
) )
assert MockPyEncoder.last
assert MockPyEncoder.last.state.xoff == xoff assert MockPyEncoder.last.state.xoff == xoff
assert MockPyEncoder.last.state.yoff == yoff assert MockPyEncoder.last.state.yoff == yoff
assert MockPyEncoder.last.state.xsize == xsize assert MockPyEncoder.last.state.xsize == xsize
@ -329,6 +334,7 @@ class TestPyEncoder(CodecsTest):
fp = BytesIO() fp = BytesIO()
ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")])
assert MockPyEncoder.last
assert MockPyEncoder.last.state.xoff == 0 assert MockPyEncoder.last.state.xoff == 0
assert MockPyEncoder.last.state.yoff == 0 assert MockPyEncoder.last.state.yoff == 0
assert MockPyEncoder.last.state.xsize == 200 assert MockPyEncoder.last.state.xsize == 200
@ -375,7 +381,7 @@ class TestPyEncoder(CodecsTest):
def test_encode(self) -> None: def test_encode(self) -> None:
encoder = ImageFile.PyEncoder(None) encoder = ImageFile.PyEncoder(None)
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
encoder.encode(None) encoder.encode(0)
bytes_consumed, errcode = encoder.encode_to_pyfd() bytes_consumed, errcode = encoder.encode_to_pyfd()
assert bytes_consumed == 0 assert bytes_consumed == 0

View File

@ -34,7 +34,9 @@ pytestmark = skip_unless_feature("freetype2")
def test_sanity() -> None: def test_sanity() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@pytest.fixture( @pytest.fixture(
@ -207,7 +209,7 @@ def test_getlength(
assert length == length_raqm assert length == length_raqm
def test_float_size() -> None: def test_float_size(layout_engine: ImageFont.Layout) -> None:
lengths = [] lengths = []
for size in (48, 48.5, 49): for size in (48, 48.5, 49):
f = ImageFont.truetype( f = ImageFont.truetype(
@ -222,7 +224,7 @@ def test_render_multiline(font: ImageFont.FreeTypeFont) -> None:
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
line_spacing = font.getbbox("A")[3] + 4 line_spacing = font.getbbox("A")[3] + 4
lines = TEST_TEXT.split("\n") lines = TEST_TEXT.split("\n")
y = 0 y: float = 0
for line in lines: for line in lines:
draw.text((0, y), line, font=font) draw.text((0, y), line, font=font)
y += line_spacing y += line_spacing
@ -492,8 +494,8 @@ def test_default_font() -> None:
assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png") assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png")
@pytest.mark.parametrize("mode", (None, "1", "RGBA")) @pytest.mark.parametrize("mode", ("", "1", "RGBA"))
def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None: def test_getbbox(font: ImageFont.FreeTypeFont, mode: str) -> None:
assert (0, 4, 12, 16) == font.getbbox("A", mode) assert (0, 4, 12, 16) == font.getbbox("A", mode)
@ -547,11 +549,10 @@ def test_find_font(
def loadable_font( def loadable_font(
filepath: str, size: int, index: int, encoding: str, *args: Any filepath: str, size: int, index: int, encoding: str, *args: Any
): ):
_freeTypeFont = getattr(ImageFont, "_FreeTypeFont")
if filepath == path_to_fake: if filepath == path_to_fake:
return ImageFont._FreeTypeFont( return _freeTypeFont(FONT_PATH, size, index, encoding, *args)
FONT_PATH, size, index, encoding, *args return _freeTypeFont(filepath, size, index, encoding, *args)
)
return ImageFont._FreeTypeFont(filepath, size, index, encoding, *args)
m.setattr(ImageFont, "FreeTypeFont", loadable_font) m.setattr(ImageFont, "FreeTypeFont", loadable_font)
font = ImageFont.truetype(fontname) font = ImageFont.truetype(fontname)
@ -563,6 +564,7 @@ def test_find_font(
# catching syntax like errors # catching syntax like errors
monkeypatch.setattr(sys, "platform", platform) monkeypatch.setattr(sys, "platform", platform)
if platform == "linux": if platform == "linux":
monkeypatch.setenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/")
def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]:
@ -630,7 +632,9 @@ def test_complex_font_settings() -> None:
def test_variation_get(font: ImageFont.FreeTypeFont) -> None: def test_variation_get(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.get_variation_names() font.get_variation_names()
@ -700,7 +704,9 @@ def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None
def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.set_variation_by_name("Bold") font.set_variation_by_name("Bold")
@ -725,7 +731,9 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.set_variation_by_axes([100]) font.set_variation_by_axes([100])

View File

@ -33,8 +33,11 @@ def test_default_font(font: ImageFont.ImageFont) -> None:
def test_without_freetype() -> None: def test_without_freetype() -> None:
original_core = ImageFont.core original_core = ImageFont.core
if features.check_module("freetype2"): if features.check_module("freetype2"):
ImageFont.core = _util.DeferredError(ImportError) ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
try: try:
with pytest.raises(ImportError):
ImageFont.truetype("Tests/fonts/FreeMono.ttf")
assert isinstance(ImageFont.load_default(), ImageFont.ImageFont) assert isinstance(ImageFont.load_default(), ImageFont.ImageFont)
with pytest.raises(ImportError): with pytest.raises(ImportError):

View File

@ -454,7 +454,7 @@ def test_autocontrast_cutoff() -> None:
# Test the cutoff argument of autocontrast # Test the cutoff argument of autocontrast
with Image.open("Tests/images/bw_gradient.png") as img: with Image.open("Tests/images/bw_gradient.png") as img:
def autocontrast(cutoff: int | tuple[int, int]): def autocontrast(cutoff: int | tuple[int, int]) -> list[int]:
return ImageOps.autocontrast(img, cutoff).histogram() return ImageOps.autocontrast(img, cutoff).histogram()
assert autocontrast(10) == autocontrast((10, 10)) assert autocontrast(10) == autocontrast((10, 10))

View File

@ -4,11 +4,11 @@ from typing import Generator
import pytest import pytest
from PIL import Image, ImageFilter from PIL import Image, ImageFile, ImageFilter
@pytest.fixture @pytest.fixture
def test_images() -> Generator[dict[str, Image.Image], None, None]: def test_images() -> Generator[dict[str, ImageFile.ImageFile], None, None]:
ims = { ims = {
"im": Image.open("Tests/images/hopper.ppm"), "im": Image.open("Tests/images/hopper.ppm"),
"snakes": Image.open("Tests/images/color_snakes.png"), "snakes": Image.open("Tests/images/color_snakes.png"),
@ -20,7 +20,7 @@ def test_images() -> Generator[dict[str, Image.Image], None, None]:
im.close() im.close()
def test_filter_api(test_images: dict[str, Image.Image]) -> None: def test_filter_api(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
test_filter = ImageFilter.GaussianBlur(2.0) test_filter = ImageFilter.GaussianBlur(2.0)
@ -34,7 +34,7 @@ def test_filter_api(test_images: dict[str, Image.Image]) -> None:
assert i.size == (128, 128) assert i.size == (128, 128)
def test_usm_formats(test_images: dict[str, Image.Image]) -> None: def test_usm_formats(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
usm = ImageFilter.UnsharpMask usm = ImageFilter.UnsharpMask
@ -52,13 +52,12 @@ def test_usm_formats(test_images: dict[str, Image.Image]) -> None:
im.convert("YCbCr").filter(usm) im.convert("YCbCr").filter(usm)
def test_blur_formats(test_images: dict[str, Image.Image]) -> None: def test_blur_formats(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
blur = ImageFilter.GaussianBlur blur = ImageFilter.GaussianBlur
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.convert("1").filter(blur) im.convert("1").filter(blur)
blur(im.convert("L"))
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.convert("I").filter(blur) im.convert("I").filter(blur)
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -70,7 +69,7 @@ def test_blur_formats(test_images: dict[str, Image.Image]) -> None:
im.convert("YCbCr").filter(blur) im.convert("YCbCr").filter(blur)
def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: def test_usm_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
snakes = test_images["snakes"] snakes = test_images["snakes"]
src = snakes.convert("RGB") src = snakes.convert("RGB")
@ -79,7 +78,7 @@ def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None:
assert i.tobytes() == src.tobytes() assert i.tobytes() == src.tobytes()
def test_blur_accuracy(test_images: dict[str, Image.Image]) -> None: def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
snakes = test_images["snakes"] snakes = test_images["snakes"]
i = snakes.filter(ImageFilter.GaussianBlur(0.4)) i = snakes.filter(ImageFilter.GaussianBlur(0.4))

View File

@ -45,7 +45,7 @@ def test_getcolor() -> None:
# Test unknown color specifier # Test unknown color specifier
with pytest.raises(ValueError): with pytest.raises(ValueError):
palette.getcolor("unknown") palette.getcolor("unknown") # type: ignore[arg-type]
def test_getcolor_rgba_color_rgb_palette() -> None: def test_getcolor_rgba_color_rgb_palette() -> None:

View File

@ -41,18 +41,13 @@ def test_rgb() -> None:
checkrgb(0, 0, 255) checkrgb(0, 0, 255)
def test_image() -> None: @pytest.mark.parametrize("mode", ("1", "RGB", "RGBA", "L", "P", "I;16"))
modes = ["1", "RGB", "RGBA", "L", "P"] def test_image(mode: str) -> None:
qt_format = ImageQt.QImage.Format if ImageQt.qt_version == "6" else ImageQt.QImage im = hopper(mode)
if hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im))
modes.append("I;16") if mode not in ("RGB", "RGBA"):
im = im.convert("RGB")
for mode in modes: assert_image_similar(roundtripped_im, im, 1)
im = hopper(mode)
roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im))
if mode not in ("RGB", "RGBA"):
im = im.convert("RGB")
assert_image_similar(roundtripped_im, im, 1)
def test_closed_file() -> None: def test_closed_file() -> None:

View File

@ -25,10 +25,10 @@ def test_sanity() -> None:
st.stddev st.stddev
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
st.spam() st.spam() # type: ignore[attr-defined]
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageStat.Stat(1) ImageStat.Stat(1) # type: ignore[arg-type]
def test_hopper() -> None: def test_hopper() -> None:

View File

@ -70,7 +70,7 @@ if is_win32():
] ]
CreateDIBSection.restype = ctypes.wintypes.HBITMAP CreateDIBSection.restype = ctypes.wintypes.HBITMAP
def serialize_dib(bi, pixels) -> bytearray: def serialize_dib(bi: BITMAPINFOHEADER, pixels: ctypes.c_void_p) -> bytearray:
bf = BITMAPFILEHEADER() bf = BITMAPFILEHEADER()
bf.bfType = 0x4D42 bf.bfType = 0x4D42
bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize

View File

@ -11,7 +11,7 @@ import pytest
"args, report", "args, report",
((["PIL"], False), (["PIL", "--report"], True), (["PIL.report"], True)), ((["PIL"], False), (["PIL", "--report"], True), (["PIL.report"], True)),
) )
def test_main(args, report) -> None: def test_main(args: list[str], report: bool) -> None:
args = [sys.executable, "-m"] + args args = [sys.executable, "-m"] + args
out = subprocess.check_output(args).decode("utf-8") out = subprocess.check_output(args).decode("utf-8")
lines = out.splitlines() lines = out.splitlines()

View File

@ -1,20 +1,25 @@
from __future__ import annotations from __future__ import annotations
import warnings import warnings
from typing import TYPE_CHECKING
import pytest import pytest
from PIL import Image from PIL import Image, _typing
from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature
numpy = pytest.importorskip("numpy", reason="NumPy not installed") if TYPE_CHECKING:
import numpy
import numpy.typing as npt
else:
numpy = pytest.importorskip("numpy", reason="NumPy not installed")
TEST_IMAGE_SIZE = (10, 10) TEST_IMAGE_SIZE = (10, 10)
def test_numpy_to_image() -> None: def test_numpy_to_image() -> None:
def to_image(dtype, bands: int = 1, boolean: int = 0) -> Image.Image: def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image:
if bands == 1: if bands == 1:
if boolean: if boolean:
data = [0, 255] * 50 data = [0, 255] * 50
@ -99,14 +104,14 @@ def test_1d_array() -> None:
assert_image(Image.fromarray(a), "L", (1, 5)) assert_image(Image.fromarray(a), "L", (1, 5))
def _test_img_equals_nparray(img: Image.Image, np) -> None: def _test_img_equals_nparray(img: Image.Image, np_img: _typing.NumpyArray) -> None:
assert len(np.shape) >= 2 assert len(np_img.shape) >= 2
np_size = np.shape[1], np.shape[0] np_size = np_img.shape[1], np_img.shape[0]
assert img.size == np_size assert img.size == np_size
px = img.load() px = img.load()
for x in range(0, img.size[0], int(img.size[0] / 10)): for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)): for y in range(0, img.size[1], int(img.size[1] / 10)):
assert_deep_equal(px[x, y], np[y, x]) assert_deep_equal(px[x, y], np_img[y, x])
def test_16bit() -> None: def test_16bit() -> None:
@ -157,7 +162,7 @@ def test_save_tiff_uint16() -> None:
("HSV", numpy.uint8), ("HSV", numpy.uint8),
), ),
) )
def test_to_array(mode: str, dtype) -> None: def test_to_array(mode: str, dtype: npt.DTypeLike) -> None:
img = hopper(mode) img = hopper(mode)
# Resize to non-square # Resize to non-square
@ -207,7 +212,7 @@ def test_putdata() -> None:
numpy.float64, numpy.float64,
), ),
) )
def test_roundtrip_eye(dtype) -> None: def test_roundtrip_eye(dtype: npt.DTypeLike) -> None:
arr = numpy.eye(10, dtype=dtype) arr = numpy.eye(10, dtype=dtype)
numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr)))

View File

@ -54,14 +54,10 @@ def test_stdout(buffer: bool) -> None:
# Temporarily redirect stdout # Temporarily redirect stdout
old_stdout = sys.stdout old_stdout = sys.stdout
if buffer: class MyStdOut:
buffer = BytesIO()
class MyStdOut: mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
buffer = BytesIO()
mystdout = MyStdOut()
else:
mystdout = BytesIO()
sys.stdout = mystdout sys.stdout = mystdout
@ -71,6 +67,6 @@ def test_stdout(buffer: bool) -> None:
# Reset stdout # Reset stdout
sys.stdout = old_stdout sys.stdout = old_stdout
if buffer: if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer mystdout = mystdout.buffer
assert mystdout.getvalue() != b"" assert mystdout.getvalue() != b""

View File

@ -46,7 +46,7 @@ def roundtrip(expected: Image.Image) -> None:
@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed")
def test_sanity(tmp_path: Path) -> None: def test_sanity(tmp_path: Path) -> None:
# Segfault test # Segfault test
app = QApplication([]) app: QApplication | None = QApplication([])
ex = Example() ex = Example()
assert app # Silence warning assert app # Silence warning
assert ex # Silence warning assert ex # Silence warning

View File

@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Callable from typing import IO, Callable
import pytest import pytest
@ -22,11 +23,11 @@ class TestShellInjection:
self, self,
tmp_path: Path, tmp_path: Path,
src_img: Image.Image, src_img: Image.Image,
save_func: Callable[[Image.Image, int, str], None], save_func: Callable[[Image.Image, IO[bytes], str | bytes], None],
) -> None: ) -> None:
for filename in test_filenames: for filename in test_filenames:
dest_file = str(tmp_path / filename) dest_file = str(tmp_path / filename)
save_func(src_img, 0, dest_file) save_func(src_img, BytesIO(), dest_file)
# If file can't be opened, shell injection probably occurred # If file can't be opened, shell injection probably occurred
with Image.open(dest_file) as im: with Image.open(dest_file) as im:
im.load() im.load()

View File

@ -2,7 +2,7 @@
# install libimagequant # install libimagequant
archive_name=libimagequant archive_name=libimagequant
archive_version=4.3.0 archive_version=4.3.1
archive=$archive_name-$archive_version archive=$archive_name-$archive_version

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# install webp # install webp
archive=libwebp-1.3.2 archive=libwebp-1.4.0
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz

View File

@ -9,9 +9,9 @@ PAPER =
BUILDDIR = _build BUILDDIR = _build
# Internal variables. # Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_a4 = --define latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter PAPEROPT_letter = --define latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . ALLSPHINXOPTS = --doctree-dir $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others # the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
@ -51,42 +51,42 @@ install-sphinx:
.PHONY: html .PHONY: html
html: html:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b html -W --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html $(SPHINXBUILD) --builder html --fail-on-warning --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo @echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html." @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
.PHONY: dirhtml .PHONY: dirhtml
dirhtml: dirhtml:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml $(SPHINXBUILD) --builder dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo @echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
.PHONY: singlehtml .PHONY: singlehtml
singlehtml: singlehtml:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml $(SPHINXBUILD) --builder singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo @echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
.PHONY: pickle .PHONY: pickle
pickle: pickle:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle $(SPHINXBUILD) --builder pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo @echo
@echo "Build finished; now you can process the pickle files." @echo "Build finished; now you can process the pickle files."
.PHONY: json .PHONY: json
json: json:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json $(SPHINXBUILD) --builder json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo @echo
@echo "Build finished; now you can process the JSON files." @echo "Build finished; now you can process the JSON files."
.PHONY: htmlhelp .PHONY: htmlhelp
htmlhelp: htmlhelp:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp $(SPHINXBUILD) --builder htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo @echo
@echo "Build finished; now you can run HTML Help Workshop with the" \ @echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp." ".hhp project file in $(BUILDDIR)/htmlhelp."
@ -94,7 +94,7 @@ htmlhelp:
.PHONY: qthelp .PHONY: qthelp
qthelp: qthelp:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp $(SPHINXBUILD) --builder qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo @echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:" ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@ -105,7 +105,7 @@ qthelp:
.PHONY: devhelp .PHONY: devhelp
devhelp: devhelp:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp $(SPHINXBUILD) --builder devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo @echo
@echo "Build finished." @echo "Build finished."
@echo "To view the help file:" @echo "To view the help file:"
@ -116,14 +116,14 @@ devhelp:
.PHONY: epub .PHONY: epub
epub: epub:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub $(SPHINXBUILD) --builder epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo @echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub." @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
.PHONY: latex .PHONY: latex
latex: latex:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo @echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \ @echo "Run \`make' in that directory to run these through (pdf)latex" \
@ -132,7 +132,7 @@ latex:
.PHONY: latexpdf .PHONY: latexpdf
latexpdf: latexpdf:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..." @echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf $(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
@ -140,21 +140,21 @@ latexpdf:
.PHONY: text .PHONY: text
text: text:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text $(SPHINXBUILD) --builder text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo @echo
@echo "Build finished. The text files are in $(BUILDDIR)/text." @echo "Build finished. The text files are in $(BUILDDIR)/text."
.PHONY: man .PHONY: man
man: man:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man $(SPHINXBUILD) --builder man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo @echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man." @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
.PHONY: texinfo .PHONY: texinfo
texinfo: texinfo:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo @echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \ @echo "Run \`make' in that directory to run these through makeinfo" \
@ -163,7 +163,7 @@ texinfo:
.PHONY: info .PHONY: info
info: info:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..." @echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
@ -171,21 +171,21 @@ info:
.PHONY: gettext .PHONY: gettext
gettext: gettext:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale $(SPHINXBUILD) --builder gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo @echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
.PHONY: changes .PHONY: changes
changes: changes:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes $(SPHINXBUILD) --builder changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo @echo
@echo "The overview file is in $(BUILDDIR)/changes." @echo "The overview file is in $(BUILDDIR)/changes."
.PHONY: linkcheck .PHONY: linkcheck
linkcheck: linkcheck:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto $(SPHINXBUILD) --builder linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto
@echo @echo
@echo "Link check complete; look for any errors in the above output " \ @echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt." "or in $(BUILDDIR)/linkcheck/output.txt."
@ -193,7 +193,7 @@ linkcheck:
.PHONY: doctest .PHONY: doctest
doctest: doctest:
$(MAKE) install-sphinx $(MAKE) install-sphinx
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest $(SPHINXBUILD) --builder doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \ @echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt." "results in $(BUILDDIR)/doctest/output.txt."

View File

@ -115,6 +115,13 @@ Support for LibTIFF earlier than 4
Support for LibTIFF earlier than version 4 has been deprecated. Support for LibTIFF earlier than version 4 has been deprecated.
Upgrade to a newer version of LibTIFF instead. Upgrade to a newer version of LibTIFF instead.
ImageDraw.getdraw hints parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 10.4.0
The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated.
Removed features Removed features
---------------- ----------------

View File

@ -144,10 +144,12 @@ pixel, the Python Imaging Library provides different resampling *filters*.
.. py:currentmodule:: PIL.Image .. py:currentmodule:: PIL.Image
.. data:: Resampling.NEAREST .. data:: Resampling.NEAREST
:noindex:
Pick one nearest pixel from the input image. Ignore all other input pixels. Pick one nearest pixel from the input image. Ignore all other input pixels.
.. data:: Resampling.BOX .. data:: Resampling.BOX
:noindex:
Each pixel of source image contributes to one pixel of the Each pixel of source image contributes to one pixel of the
destination image with identical weights. destination image with identical weights.
@ -158,6 +160,7 @@ pixel, the Python Imaging Library provides different resampling *filters*.
.. versionadded:: 3.4.0 .. versionadded:: 3.4.0
.. data:: Resampling.BILINEAR .. data:: Resampling.BILINEAR
:noindex:
For resize calculate the output pixel value using linear interpolation For resize calculate the output pixel value using linear interpolation
on all pixels that may contribute to the output value. on all pixels that may contribute to the output value.
@ -165,6 +168,7 @@ pixel, the Python Imaging Library provides different resampling *filters*.
in the input image is used. in the input image is used.
.. data:: Resampling.HAMMING .. data:: Resampling.HAMMING
:noindex:
Produces a sharper image than :data:`Resampling.BILINEAR`, doesn't have Produces a sharper image than :data:`Resampling.BILINEAR`, doesn't have
dislocations on local level like with :data:`Resampling.BOX`. dislocations on local level like with :data:`Resampling.BOX`.
@ -174,6 +178,7 @@ pixel, the Python Imaging Library provides different resampling *filters*.
.. versionadded:: 3.4.0 .. versionadded:: 3.4.0
.. data:: Resampling.BICUBIC .. data:: Resampling.BICUBIC
:noindex:
For resize calculate the output pixel value using cubic interpolation For resize calculate the output pixel value using cubic interpolation
on all pixels that may contribute to the output value. on all pixels that may contribute to the output value.
@ -181,6 +186,7 @@ pixel, the Python Imaging Library provides different resampling *filters*.
in the input image is used. in the input image is used.
.. data:: Resampling.LANCZOS .. data:: Resampling.LANCZOS
:noindex:
Calculate the output pixel value using a high-quality Lanczos filter (a Calculate the output pixel value using a high-quality Lanczos filter (a
truncated sinc) on all pixels that may contribute to the output value. truncated sinc) on all pixels that may contribute to the output value.

View File

@ -68,7 +68,7 @@ Many of Pillow's features require external libraries:
* **libimagequant** provides improved color quantization * **libimagequant** provides improved color quantization
* Pillow has been tested with libimagequant **2.6-4.3** * Pillow has been tested with libimagequant **2.6-4.3.1**
* Libimagequant is licensed GPLv3, which is more restrictive than * Libimagequant is licensed GPLv3, which is more restrictive than
the Pillow license, therefore we will not be distributing binaries the Pillow license, therefore we will not be distributing binaries
with libimagequant support enabled. with libimagequant support enabled.

View File

@ -78,8 +78,6 @@ Constructing images
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
.. autofunction:: new .. autofunction:: new
.. autoclass:: SupportsArrayInterface
:show-inheritance:
.. autofunction:: fromarray .. autofunction:: fromarray
.. autofunction:: frombytes .. autofunction:: frombytes
.. autofunction:: frombuffer .. autofunction:: frombuffer
@ -197,6 +195,7 @@ This helps to get the bounding box coordinates of the input image::
.. automethod:: PIL.Image.Image.getpalette .. automethod:: PIL.Image.Image.getpalette
.. automethod:: PIL.Image.Image.getpixel .. automethod:: PIL.Image.Image.getpixel
.. automethod:: PIL.Image.Image.getprojection .. automethod:: PIL.Image.Image.getprojection
.. automethod:: PIL.Image.Image.getxmp
.. automethod:: PIL.Image.Image.histogram .. automethod:: PIL.Image.Image.histogram
.. automethod:: PIL.Image.Image.paste .. automethod:: PIL.Image.Image.paste
.. automethod:: PIL.Image.Image.point .. automethod:: PIL.Image.Image.point
@ -365,6 +364,14 @@ Classes
.. autoclass:: PIL.Image.ImagePointHandler .. autoclass:: PIL.Image.ImagePointHandler
.. autoclass:: PIL.Image.ImageTransformHandler .. autoclass:: PIL.Image.ImageTransformHandler
Protocols
---------
.. autoclass:: SupportsArrayInterface
:show-inheritance:
.. autoclass:: SupportsGetData
:show-inheritance:
Constants Constants
--------- ---------
@ -418,7 +425,6 @@ See :ref:`concept-filters` for details.
.. autoclass:: Resampling .. autoclass:: Resampling
:members: :members:
:undoc-members: :undoc-members:
:noindex:
Dither modes Dither modes
^^^^^^^^^^^^ ^^^^^^^^^^^^

View File

@ -227,6 +227,18 @@ Methods
.. versionadded:: 5.3.0 .. versionadded:: 5.3.0
.. py:method:: ImageDraw.circle(xy, radius, fill=None, outline=None, width=1)
Draws a circle with a given radius centering on a point.
.. versionadded:: 10.4.0
:param xy: The point for the center of the circle, e.g. ``(x, y)``.
:param radius: Radius of the circle.
:param outline: Color to use for the outline.
:param fill: Color to use for the fill.
:param width: The line width, in pixels.
.. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1) .. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1)
Draws an ellipse inside the given bounding box. Draws an ellipse inside the given bounding box.

View File

@ -57,6 +57,10 @@ Classes
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
.. autoclass:: PIL.ImageFile.StubHandler()
:members:
:show-inheritance:
.. autoclass:: PIL.ImageFile.StubImageFile() .. autoclass:: PIL.ImageFile.StubImageFile()
:members: :members:
:show-inheritance: :show-inheritance:

View File

@ -33,6 +33,10 @@ Internal Modules
Provides a convenient way to import type hints that are not available Provides a convenient way to import type hints that are not available
on some Python versions. on some Python versions.
.. py:class:: NumpyArray
Typing alias.
.. py:class:: StrOrBytesPath .. py:class:: StrOrBytesPath
Typing alias. Typing alias.

View File

@ -34,6 +34,11 @@ Support for LibTIFF earlier than 4
Support for LibTIFF earlier than version 4 has been deprecated. Support for LibTIFF earlier than version 4 has been deprecated.
Upgrade to a newer version of LibTIFF instead. Upgrade to a newer version of LibTIFF instead.
ImageDraw.getdraw hints parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated.
API Changes API Changes
=========== ===========
@ -45,6 +50,13 @@ TODO
API Additions API Additions
============= =============
ImageDraw.circle
^^^^^^^^^^^^^^^^
Added :py:meth:`~PIL.ImageDraw.ImageDraw.circle`. It provides the same functionality as
:py:meth:`~PIL.ImageDraw.ImageDraw.ellipse`, but instead of taking a bounding box, it
takes a center point and radius.
TODO TODO
^^^^ ^^^^

View File

@ -18,9 +18,9 @@ is not secure.
- :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve - :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve
orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead. orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead.
- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It - ``getxmp()`` was added to :py:class:`~PIL.JpegImagePlugin.JpegImageFile` in Pillow
will now use ``defusedxml`` instead. If the dependency is not present, an empty 8.2.0. It will now use ``defusedxml`` instead. If the dependency is not present, an
dictionary will be returned and a warning raised. empty dictionary will be returned and a warning raised.
Deprecations Deprecations
============ ============

View File

@ -37,7 +37,9 @@ IMAGEQUANT_ROOT = None
JPEG2K_ROOT = None JPEG2K_ROOT = None
JPEG_ROOT = None JPEG_ROOT = None
LCMS_ROOT = None LCMS_ROOT = None
RAQM_ROOT = None
TIFF_ROOT = None TIFF_ROOT = None
WEBP_ROOT = None
ZLIB_ROOT = None ZLIB_ROOT = None
FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ
@ -459,6 +461,8 @@ class pil_build_ext(build_ext):
"FREETYPE_ROOT": "freetype2", "FREETYPE_ROOT": "freetype2",
"HARFBUZZ_ROOT": "harfbuzz", "HARFBUZZ_ROOT": "harfbuzz",
"FRIBIDI_ROOT": "fribidi", "FRIBIDI_ROOT": "fribidi",
"RAQM_ROOT": "raqm",
"WEBP_ROOT": "libwebp",
"LCMS_ROOT": "lcms2", "LCMS_ROOT": "lcms2",
"IMAGEQUANT_ROOT": "libimagequant", "IMAGEQUANT_ROOT": "libimagequant",
}.items(): }.items():

View File

@ -103,7 +103,7 @@ def bdf_char(
class BdfFontFile(FontFile.FontFile): class BdfFontFile(FontFile.FontFile):
"""Font file plugin for the X11 BDF format.""" """Font file plugin for the X11 BDF format."""
def __init__(self, fp: BinaryIO): def __init__(self, fp: BinaryIO) -> None:
super().__init__() super().__init__()
s = fp.readline() s = fp.readline()

View File

@ -31,10 +31,12 @@ BLP files come in many different flavours:
from __future__ import annotations from __future__ import annotations
import abc
import os import os
import struct import struct
from enum import IntEnum from enum import IntEnum
from io import BytesIO from io import BytesIO
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
@ -55,11 +57,13 @@ class AlphaEncoding(IntEnum):
DXT5 = 7 DXT5 = 7
def unpack_565(i): def unpack_565(i: int) -> tuple[int, int, int]:
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
def decode_dxt1(data, alpha=False): def decode_dxt1(
data: bytes, alpha: bool = False
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
""" """
input: one "row" of data (i.e. will produce 4*width pixels) input: one "row" of data (i.e. will produce 4*width pixels)
""" """
@ -67,9 +71,9 @@ def decode_dxt1(data, alpha=False):
blocks = len(data) // 8 # number of blocks in row blocks = len(data) // 8 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray()) ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block in range(blocks): for block_index in range(blocks):
# Decode next 8-byte block. # Decode next 8-byte block.
idx = block * 8 idx = block_index * 8
color0, color1, bits = struct.unpack_from("<HHI", data, idx) color0, color1, bits = struct.unpack_from("<HHI", data, idx)
r0, g0, b0 = unpack_565(color0) r0, g0, b0 = unpack_565(color0)
@ -114,7 +118,7 @@ def decode_dxt1(data, alpha=False):
return ret return ret
def decode_dxt3(data): def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
""" """
input: one "row" of data (i.e. will produce 4*width pixels) input: one "row" of data (i.e. will produce 4*width pixels)
""" """
@ -122,8 +126,8 @@ def decode_dxt3(data):
blocks = len(data) // 16 # number of blocks in row blocks = len(data) // 16 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray()) ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block in range(blocks): for block_index in range(blocks):
idx = block * 16 idx = block_index * 16
block = data[idx : idx + 16] block = data[idx : idx + 16]
# Decode next 16-byte block. # Decode next 16-byte block.
bits = struct.unpack_from("<8B", block) bits = struct.unpack_from("<8B", block)
@ -167,7 +171,7 @@ def decode_dxt3(data):
return ret return ret
def decode_dxt5(data): def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
""" """
input: one "row" of data (i.e. will produce 4 * width pixels) input: one "row" of data (i.e. will produce 4 * width pixels)
""" """
@ -175,8 +179,8 @@ def decode_dxt5(data):
blocks = len(data) // 16 # number of blocks in row blocks = len(data) // 16 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray()) ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block in range(blocks): for block_index in range(blocks):
idx = block * 16 idx = block_index * 16
block = data[idx : idx + 16] block = data[idx : idx + 16]
# Decode next 16-byte block. # Decode next 16-byte block.
a0, a1 = struct.unpack_from("<BB", block) a0, a1 = struct.unpack_from("<BB", block)
@ -275,7 +279,7 @@ class BlpImageFile(ImageFile.ImageFile):
class _BLPBaseDecoder(ImageFile.PyDecoder): class _BLPBaseDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer): def decode(self, buffer: bytes) -> tuple[int, int]:
try: try:
self._read_blp_header() self._read_blp_header()
self._load() self._load()
@ -284,7 +288,12 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
raise OSError(msg) from e raise OSError(msg) from e
return -1, 0 return -1, 0
def _read_blp_header(self): @abc.abstractmethod
def _load(self) -> None:
pass
def _read_blp_header(self) -> None:
assert self.fd is not None
self.fd.seek(4) self.fd.seek(4)
(self._blp_compression,) = struct.unpack("<i", self._safe_read(4)) (self._blp_compression,) = struct.unpack("<i", self._safe_read(4))
@ -303,10 +312,10 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
self._blp_offsets = struct.unpack("<16I", self._safe_read(16 * 4)) self._blp_offsets = struct.unpack("<16I", self._safe_read(16 * 4))
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): def _safe_read(self, length: int) -> bytes:
return ImageFile._safe_read(self.fd, length) return ImageFile._safe_read(self.fd, length)
def _read_palette(self): def _read_palette(self) -> list[tuple[int, int, int, int]]:
ret = [] ret = []
for i in range(256): for i in range(256):
try: try:
@ -316,7 +325,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
ret.append((b, g, r, a)) ret.append((b, g, r, a))
return ret return ret
def _read_bgra(self, palette): def _read_bgra(self, palette: list[tuple[int, int, int, int]]) -> bytearray:
data = bytearray() data = bytearray()
_data = BytesIO(self._safe_read(self._blp_lengths[0])) _data = BytesIO(self._safe_read(self._blp_lengths[0]))
while True: while True:
@ -325,7 +334,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
except struct.error: except struct.error:
break break
b, g, r, a = palette[offset] b, g, r, a = palette[offset]
d = (r, g, b) d: tuple[int, ...] = (r, g, b)
if self._blp_alpha_depth: if self._blp_alpha_depth:
d += (a,) d += (a,)
data.extend(d) data.extend(d)
@ -349,29 +358,30 @@ class BLP1Decoder(_BLPBaseDecoder):
msg = f"Unsupported BLP compression {repr(self._blp_encoding)}" msg = f"Unsupported BLP compression {repr(self._blp_encoding)}"
raise BLPFormatError(msg) raise BLPFormatError(msg)
def _decode_jpeg_stream(self): def _decode_jpeg_stream(self) -> None:
from .JpegImagePlugin import JpegImageFile from .JpegImagePlugin import JpegImageFile
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4)) (jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
jpeg_header = self._safe_read(jpeg_header_size) jpeg_header = self._safe_read(jpeg_header_size)
assert self.fd is not None
self._safe_read(self._blp_offsets[0] - self.fd.tell()) # What IS this? self._safe_read(self._blp_offsets[0] - self.fd.tell()) # What IS this?
data = self._safe_read(self._blp_lengths[0]) data = self._safe_read(self._blp_lengths[0])
data = jpeg_header + data data = jpeg_header + data
data = BytesIO(data) image = JpegImageFile(BytesIO(data))
image = JpegImageFile(data)
Image._decompression_bomb_check(image.size) Image._decompression_bomb_check(image.size)
if image.mode == "CMYK": if image.mode == "CMYK":
decoder_name, extents, offset, args = image.tile[0] decoder_name, extents, offset, args = image.tile[0]
image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))] image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))]
r, g, b = image.convert("RGB").split() r, g, b = image.convert("RGB").split()
image = Image.merge("RGB", (b, g, r)) reversed_image = Image.merge("RGB", (b, g, r))
self.set_as_raw(image.tobytes()) self.set_as_raw(reversed_image.tobytes())
class BLP2Decoder(_BLPBaseDecoder): class BLP2Decoder(_BLPBaseDecoder):
def _load(self): def _load(self) -> None:
palette = self._read_palette() palette = self._read_palette()
assert self.fd is not None
self.fd.seek(self._blp_offsets[0]) self.fd.seek(self._blp_offsets[0])
if self._blp_compression == 1: if self._blp_compression == 1:
@ -428,7 +438,7 @@ class BLPEncoder(ImageFile.PyEncoder):
data += b"\x00" * 4 data += b"\x00" * 4
return data return data
def encode(self, bufsize): def encode(self, bufsize: int) -> tuple[int, int, bytes]:
palette_data = self._write_palette() palette_data = self._write_palette()
offset = 20 + 16 * 4 * 2 + len(palette_data) offset = 20 + 16 * 4 * 2 + len(palette_data)
@ -446,7 +456,7 @@ class BLPEncoder(ImageFile.PyEncoder):
return len(data), 0, data return len(data), 0, data
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "P": if im.mode != "P":
msg = "Unsupported BLP image mode" msg = "Unsupported BLP image mode"
raise ValueError(msg) raise ValueError(msg)

View File

@ -25,6 +25,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import IO
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -52,7 +53,7 @@ def _accept(prefix: bytes) -> bool:
return prefix[:2] == b"BM" return prefix[:2] == b"BM"
def _dib_accept(prefix): def _dib_accept(prefix: bytes) -> bool:
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
@ -300,7 +301,8 @@ class BmpImageFile(ImageFile.ImageFile):
class BmpRleDecoder(ImageFile.PyDecoder): class BmpRleDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer): def decode(self, buffer: bytes) -> tuple[int, int]:
assert self.fd is not None
rle4 = self.args[1] rle4 = self.args[1]
data = bytearray() data = bytearray()
x = 0 x = 0
@ -394,11 +396,13 @@ SAVE = {
} }
def _dib_save(im, fp, filename): def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, False) _save(im, fp, filename, False)
def _save(im, fp, filename, bitmap_header=True): def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
) -> None:
try: try:
rawmode, bits, colors = SAVE[im.mode] rawmode, bits, colors = SAVE[im.mode]
except KeyError as e: except KeyError as e:

View File

@ -10,12 +10,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
_handler = None _handler = None
def register_handler(handler): def register_handler(handler: ImageFile.StubHandler | None) -> None:
""" """
Install application-specific BUFR image handler. Install application-specific BUFR image handler.
@ -54,11 +56,11 @@ class BufrStubImageFile(ImageFile.StubImageFile):
if loader: if loader:
loader.open(self) loader.open(self)
def _load(self): def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"): if _handler is None or not hasattr(_handler, "save"):
msg = "BUFR save handler not installed" msg = "BUFR save handler not installed"
raise OSError(msg) raise OSError(msg)

View File

@ -42,7 +42,7 @@ class DcxImageFile(PcxImageFile):
format_description = "Intel DCX" format_description = "Intel DCX"
_close_exclusive_fp_after_loading = False _close_exclusive_fp_after_loading = False
def _open(self): def _open(self) -> None:
# Header # Header
s = self.fp.read(4) s = self.fp.read(4)
if not _accept(s): if not _accept(s):
@ -58,7 +58,7 @@ class DcxImageFile(PcxImageFile):
self._offset.append(offset) self._offset.append(offset)
self._fp = self.fp self._fp = self.fp
self.frame = None self.frame = -1
self.n_frames = len(self._offset) self.n_frames = len(self._offset)
self.is_animated = self.n_frames > 1 self.is_animated = self.n_frames > 1
self.seek(0) self.seek(0)

View File

@ -16,6 +16,7 @@ import io
import struct import struct
import sys import sys
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import IO
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
from ._binary import i32le as i32 from ._binary import i32le as i32
@ -379,6 +380,7 @@ class DdsImageFile(ImageFile.ImageFile):
elif pfflags & DDPF.PALETTEINDEXED8: elif pfflags & DDPF.PALETTEINDEXED8:
self._mode = "P" self._mode = "P"
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024)) self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
self.palette.mode = "RGBA"
elif pfflags & DDPF.FOURCC: elif pfflags & DDPF.FOURCC:
offset = header_size + 4 offset = header_size + 4
if fourcc == D3DFMT.DXT1: if fourcc == D3DFMT.DXT1:
@ -479,7 +481,8 @@ class DdsImageFile(ImageFile.ImageFile):
class DdsRgbDecoder(ImageFile.PyDecoder): class DdsRgbDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer): def decode(self, buffer: bytes) -> tuple[int, int]:
assert self.fd is not None
bitcount, masks = self.args bitcount, masks = self.args
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
@ -510,7 +513,7 @@ class DdsRgbDecoder(ImageFile.PyDecoder):
return -1, 0 return -1, 0
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode not in ("RGB", "RGBA", "L", "LA"): if im.mode not in ("RGB", "RGBA", "L", "LA"):
msg = f"cannot write mode {im.mode} as DDS" msg = f"cannot write mode {im.mode} as DDS"
raise OSError(msg) raise OSError(msg)

View File

@ -27,6 +27,7 @@ import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
from ._binary import i32le as i32 from ._binary import i32le as i32
@ -228,7 +229,7 @@ class EpsImageFile(ImageFile.ImageFile):
reading_trailer_comments = False reading_trailer_comments = False
trailer_reached = False trailer_reached = False
def check_required_header_comments(): def check_required_header_comments() -> None:
if "PS-Adobe" not in self.info: if "PS-Adobe" not in self.info:
msg = 'EPS header missing "%!PS-Adobe" comment' msg = 'EPS header missing "%!PS-Adobe" comment'
raise SyntaxError(msg) raise SyntaxError(msg)
@ -236,7 +237,7 @@ class EpsImageFile(ImageFile.ImageFile):
msg = 'EPS header missing "%%BoundingBox" comment' msg = 'EPS header missing "%%BoundingBox" comment'
raise SyntaxError(msg) raise SyntaxError(msg)
def _read_comment(s): def _read_comment(s: str) -> bool:
nonlocal reading_trailer_comments nonlocal reading_trailer_comments
try: try:
m = split.match(s) m = split.match(s)
@ -244,27 +245,25 @@ class EpsImageFile(ImageFile.ImageFile):
msg = "not an EPS file" msg = "not an EPS file"
raise SyntaxError(msg) from e raise SyntaxError(msg) from e
if m: if not m:
k, v = m.group(1, 2) return False
self.info[k] = v
if k == "BoundingBox": k, v = m.group(1, 2)
if v == "(atend)": self.info[k] = v
reading_trailer_comments = True if k == "BoundingBox":
elif not self._size or ( if v == "(atend)":
trailer_reached and reading_trailer_comments reading_trailer_comments = True
): elif not self._size or (trailer_reached and reading_trailer_comments):
try: try:
# Note: The DSC spec says that BoundingBox # Note: The DSC spec says that BoundingBox
# fields should be integers, but some drivers # fields should be integers, but some drivers
# put floating point values there anyway. # put floating point values there anyway.
box = [int(float(i)) for i in v.split()] box = [int(float(i)) for i in v.split()]
self._size = box[2] - box[0], box[3] - box[1] self._size = box[2] - box[0], box[3] - box[1]
self.tile = [ self.tile = [("eps", (0, 0) + self.size, offset, (length, box))]
("eps", (0, 0) + self.size, offset, (length, box)) except Exception:
] pass
except Exception: return True
pass
return True
while True: while True:
byte = self.fp.read(1) byte = self.fp.read(1)
@ -413,7 +412,7 @@ class EpsImageFile(ImageFile.ImageFile):
# -------------------------------------------------------------------- # --------------------------------------------------------------------
def _save(im, fp, filename, eps=1): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
"""EPS Writer for the Python Imaging Library.""" """EPS Writer for the Python Imaging Library."""
# make sure image data is available # make sure image data is available

View File

@ -115,14 +115,18 @@ class FitsImageFile(ImageFile.ImageFile):
elif number_of_bits in (-32, -64): elif number_of_bits in (-32, -64):
self._mode = "F" self._mode = "F"
args = (self.mode, 0, -1) if decoder_name == "raw" else (number_of_bits,) args: tuple[str | int, ...]
if decoder_name == "raw":
args = (self.mode, 0, -1)
else:
args = (number_of_bits,)
return decoder_name, offset, args return decoder_name, offset, args
class FitsGzipDecoder(ImageFile.PyDecoder): class FitsGzipDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer): def decode(self, buffer: bytes) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
value = gzip.decompress(self.fd.read()) value = gzip.decompress(self.fd.read())

View File

@ -70,7 +70,7 @@ class FpxImageFile(ImageFile.ImageFile):
self._open_index(1) self._open_index(1)
def _open_index(self, index=1): def _open_index(self, index: int = 1) -> None:
# #
# get the Image Contents Property Set # get the Image Contents Property Set
@ -85,7 +85,7 @@ class FpxImageFile(ImageFile.ImageFile):
size = max(self.size) size = max(self.size)
i = 1 i = 1
while size > 64: while size > 64:
size = size / 2 size = size // 2
i += 1 i += 1
self.maxid = i - 1 self.maxid = i - 1
@ -118,7 +118,7 @@ class FpxImageFile(ImageFile.ImageFile):
self._open_subimage(1, self.maxid) self._open_subimage(1, self.maxid)
def _open_subimage(self, index=1, subimage=0): def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
# #
# setup tile descriptors for a given subimage # setup tile descriptors for a given subimage
@ -241,7 +241,7 @@ class FpxImageFile(ImageFile.ImageFile):
self.ole.close() self.ole.close()
super().close() super().close()
def __exit__(self, *args): def __exit__(self, *args: object) -> None:
self.ole.close() self.ole.close()
super().__exit__() super().__exit__()

View File

@ -29,8 +29,10 @@ import itertools
import math import math
import os import os
import subprocess import subprocess
import sys
from enum import IntEnum from enum import IntEnum
from functools import cached_property from functools import cached_property
from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union
from . import ( from . import (
Image, Image,
@ -45,6 +47,9 @@ from ._binary import i16le as i16
from ._binary import o8 from ._binary import o8
from ._binary import o16le as o16 from ._binary import o16le as o16
if TYPE_CHECKING:
from . import _imaging
class LoadingStrategy(IntEnum): class LoadingStrategy(IntEnum):
""".. versionadded:: 9.1.0""" """.. versionadded:: 9.1.0"""
@ -117,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile):
self._seek(0) # get ready to read first frame self._seek(0) # get ready to read first frame
@property @property
def n_frames(self): def n_frames(self) -> int:
if self._n_frames is None: if self._n_frames is None:
current = self.tell() current = self.tell()
try: try:
@ -162,11 +167,11 @@ class GifImageFile(ImageFile.ImageFile):
msg = "no more images in GIF file" msg = "no more images in GIF file"
raise EOFError(msg) from e raise EOFError(msg) from e
def _seek(self, frame, update_image=True): def _seek(self, frame: int, update_image: bool = True) -> None:
if frame == 0: if frame == 0:
# rewind # rewind
self.__offset = 0 self.__offset = 0
self.dispose = None self.dispose: _imaging.ImagingCore | None = None
self.__frame = -1 self.__frame = -1
self._fp.seek(self.__rewind) self._fp.seek(self.__rewind)
self.disposal_method = 0 self.disposal_method = 0
@ -194,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile):
msg = "no more images in GIF file" msg = "no more images in GIF file"
raise EOFError(msg) raise EOFError(msg)
palette = None palette: ImagePalette.ImagePalette | Literal[False] | None = None
info = {} info: dict[str, Any] = {}
frame_transparency = None frame_transparency = None
interlace = None interlace = None
frame_dispose_extent = None frame_dispose_extent = None
@ -212,7 +217,7 @@ class GifImageFile(ImageFile.ImageFile):
# #
s = self.fp.read(1) s = self.fp.read(1)
block = self.data() block = self.data()
if s[0] == 249: if s[0] == 249 and block is not None:
# #
# graphic control extension # graphic control extension
# #
@ -248,14 +253,14 @@ class GifImageFile(ImageFile.ImageFile):
info["comment"] = comment info["comment"] = comment
s = None s = None
continue continue
elif s[0] == 255 and frame == 0: elif s[0] == 255 and frame == 0 and block is not None:
# #
# application extension # application extension
# #
info["extension"] = block, self.fp.tell() info["extension"] = block, self.fp.tell()
if block[:11] == b"NETSCAPE2.0": if block[:11] == b"NETSCAPE2.0":
block = self.data() block = self.data()
if len(block) >= 3 and block[0] == 1: if block and len(block) >= 3 and block[0] == 1:
self.info["loop"] = i16(block, 1) self.info["loop"] = i16(block, 1)
while self.data(): while self.data():
pass pass
@ -336,60 +341,60 @@ class GifImageFile(ImageFile.ImageFile):
self._mode = "RGB" self._mode = "RGB"
self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
def _rgb(color): def _rgb(color: int) -> tuple[int, int, int]:
if self._frame_palette: if self._frame_palette:
if color * 3 + 3 > len(self._frame_palette.palette): if color * 3 + 3 > len(self._frame_palette.palette):
color = 0 color = 0
color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
else: else:
color = (color, color, color) return (color, color, color)
return color
self.dispose = None
self.dispose_extent = frame_dispose_extent self.dispose_extent = frame_dispose_extent
try: if self.dispose_extent and self.disposal_method >= 2:
if self.disposal_method < 2: try:
# do not dispose or none specified if self.disposal_method == 2:
self.dispose = None # replace with background colour
elif self.disposal_method == 2:
# replace with background colour
# only dispose the extent in this frame
x0, y0, x1, y1 = self.dispose_extent
dispose_size = (x1 - x0, y1 - y0)
Image._decompression_bomb_check(dispose_size)
# by convention, attempt to use transparency first
dispose_mode = "P"
color = self.info.get("transparency", frame_transparency)
if color is not None:
if self.mode in ("RGB", "RGBA"):
dispose_mode = "RGBA"
color = _rgb(color) + (0,)
else:
color = self.info.get("background", 0)
if self.mode in ("RGB", "RGBA"):
dispose_mode = "RGB"
color = _rgb(color)
self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
else:
# replace with previous contents
if self.im is not None:
# only dispose the extent in this frame # only dispose the extent in this frame
self.dispose = self._crop(self.im, self.dispose_extent)
elif frame_transparency is not None:
x0, y0, x1, y1 = self.dispose_extent x0, y0, x1, y1 = self.dispose_extent
dispose_size = (x1 - x0, y1 - y0) dispose_size = (x1 - x0, y1 - y0)
Image._decompression_bomb_check(dispose_size) Image._decompression_bomb_check(dispose_size)
# by convention, attempt to use transparency first
dispose_mode = "P" dispose_mode = "P"
color = frame_transparency color = self.info.get("transparency", frame_transparency)
if self.mode in ("RGB", "RGBA"): if color is not None:
dispose_mode = "RGBA" if self.mode in ("RGB", "RGBA"):
color = _rgb(frame_transparency) + (0,) dispose_mode = "RGBA"
color = _rgb(color) + (0,)
else:
color = self.info.get("background", 0)
if self.mode in ("RGB", "RGBA"):
dispose_mode = "RGB"
color = _rgb(color)
self.dispose = Image.core.fill(dispose_mode, dispose_size, color) self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
except AttributeError: else:
pass # replace with previous contents
if self.im is not None:
# only dispose the extent in this frame
self.dispose = self._crop(self.im, self.dispose_extent)
elif frame_transparency is not None:
x0, y0, x1, y1 = self.dispose_extent
dispose_size = (x1 - x0, y1 - y0)
Image._decompression_bomb_check(dispose_size)
dispose_mode = "P"
color = frame_transparency
if self.mode in ("RGB", "RGBA"):
dispose_mode = "RGBA"
color = _rgb(frame_transparency) + (0,)
self.dispose = Image.core.fill(
dispose_mode, dispose_size, color
)
except AttributeError:
pass
if interlace is not None: if interlace is not None:
transparency = -1 transparency = -1
@ -428,7 +433,7 @@ class GifImageFile(ImageFile.ImageFile):
self._prev_im = self.im self._prev_im = self.im
if self._frame_palette: if self._frame_palette:
self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
self.im.putpalette(*self._frame_palette.getdata()) self.im.putpalette("RGB", *self._frame_palette.getdata())
else: else:
self.im = None self.im = None
self._mode = temp_mode self._mode = temp_mode
@ -453,6 +458,8 @@ class GifImageFile(ImageFile.ImageFile):
frame_im = self.im.convert("RGBA") frame_im = self.im.convert("RGBA")
else: else:
frame_im = self.im.convert("RGB") frame_im = self.im.convert("RGB")
assert self.dispose_extent is not None
frame_im = self._crop(frame_im, self.dispose_extent) frame_im = self._crop(frame_im, self.dispose_extent)
self.im = self._prev_im self.im = self._prev_im
@ -498,7 +505,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image:
return im.convert("L") return im.convert("L")
def _normalize_palette(im, palette, info): _Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette]
def _normalize_palette(
im: Image.Image, palette: _Palette | None, info: dict[str, Any]
) -> Image.Image:
""" """
Normalizes the palette for image. Normalizes the palette for image.
- Sets the palette to the incoming palette, if provided. - Sets the palette to the incoming palette, if provided.
@ -526,8 +538,10 @@ def _normalize_palette(im, palette, info):
source_palette = bytearray(i // 3 for i in range(768)) source_palette = bytearray(i // 3 for i in range(768))
im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
used_palette_colors: list[int] | None
if palette: if palette:
used_palette_colors = [] used_palette_colors = []
assert source_palette is not None
for i in range(0, len(source_palette), 3): for i in range(0, len(source_palette), 3):
source_color = tuple(source_palette[i : i + 3]) source_color = tuple(source_palette[i : i + 3])
index = im.palette.colors.get(source_color) index = im.palette.colors.get(source_color)
@ -558,7 +572,11 @@ def _normalize_palette(im, palette, info):
return im return im
def _write_single_frame(im, fp, palette): def _write_single_frame(
im: Image.Image,
fp: IO[bytes],
palette: _Palette | None,
) -> None:
im_out = _normalize_mode(im) im_out = _normalize_mode(im)
for k, v in im_out.info.items(): for k, v in im_out.info.items():
im.encoderinfo.setdefault(k, v) im.encoderinfo.setdefault(k, v)
@ -579,7 +597,9 @@ def _write_single_frame(im, fp, palette):
fp.write(b"\0") # end of image data fp.write(b"\0") # end of image data
def _getbbox(base_im, im_frame): def _getbbox(
base_im: Image.Image, im_frame: Image.Image
) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im):
im_frame = im_frame.convert("RGBA") im_frame = im_frame.convert("RGBA")
base_im = base_im.convert("RGBA") base_im = base_im.convert("RGBA")
@ -587,12 +607,20 @@ def _getbbox(base_im, im_frame):
return delta, delta.getbbox(alpha_only=False) return delta, delta.getbbox(alpha_only=False)
def _write_multiple_frames(im, fp, palette): 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], palette: _Palette | None
) -> bool:
duration = im.encoderinfo.get("duration") duration = im.encoderinfo.get("duration")
disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
im_frames = [] im_frames: list[_Frame] = []
previous_im = None previous_im: Image.Image | None = None
frame_count = 0 frame_count = 0
background_im = None background_im = None
for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
@ -618,24 +646,22 @@ def _write_multiple_frames(im, fp, palette):
frame_count += 1 frame_count += 1
diff_frame = None diff_frame = None
if im_frames: if im_frames and previous_im:
# delta frame # delta frame
delta, bbox = _getbbox(previous_im, im_frame) delta, bbox = _getbbox(previous_im, im_frame)
if not bbox: if not bbox:
# This frame is identical to the previous frame # This frame is identical to the previous frame
if encoderinfo.get("duration"): if encoderinfo.get("duration"):
im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
"duration"
]
continue continue
if im_frames[-1]["encoderinfo"].get("disposal") == 2: if im_frames[-1].encoderinfo.get("disposal") == 2:
if background_im is None: if background_im is None:
color = im.encoderinfo.get( color = im.encoderinfo.get(
"transparency", im.info.get("transparency", (0, 0, 0)) "transparency", im.info.get("transparency", (0, 0, 0))
) )
background = _get_background(im_frame, color) background = _get_background(im_frame, color)
background_im = Image.new("P", im_frame.size, background) background_im = Image.new("P", im_frame.size, background)
background_im.putpalette(im_frames[0]["im"].palette) background_im.putpalette(im_frames[0].im.palette)
bbox = _getbbox(background_im, im_frame)[1] bbox = _getbbox(background_im, im_frame)[1]
elif encoderinfo.get("optimize") and im_frame.mode != "1": elif encoderinfo.get("optimize") and im_frame.mode != "1":
if "transparency" not in encoderinfo: if "transparency" not in encoderinfo:
@ -681,39 +707,39 @@ def _write_multiple_frames(im, fp, palette):
else: else:
bbox = None bbox = None
previous_im = im_frame previous_im = im_frame
im_frames.append( im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo))
{"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo}
)
if len(im_frames) == 1: if len(im_frames) == 1:
if "duration" in im.encoderinfo: if "duration" in im.encoderinfo:
# Since multiple frames will not be written, use the combined duration # Since multiple frames will not be written, use the combined duration
im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"]
return return False
for frame_data in im_frames: for frame_data in im_frames:
im_frame = frame_data["im"] im_frame = frame_data.im
if not frame_data["bbox"]: if not frame_data.bbox:
# global header # global header
for s in _get_global_header(im_frame, frame_data["encoderinfo"]): for s in _get_global_header(im_frame, frame_data.encoderinfo):
fp.write(s) fp.write(s)
offset = (0, 0) offset = (0, 0)
else: else:
# compress difference # compress difference
if not palette: if not palette:
frame_data["encoderinfo"]["include_color_table"] = True frame_data.encoderinfo["include_color_table"] = True
im_frame = im_frame.crop(frame_data["bbox"]) im_frame = im_frame.crop(frame_data.bbox)
offset = frame_data["bbox"][:2] offset = frame_data.bbox[:2]
_write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo)
return True return True
def _save_all(im, fp, filename): 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, save_all=False): def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
# header # header
if "palette" in im.encoderinfo or "palette" in im.info: if "palette" in im.encoderinfo or "palette" in im.info:
palette = im.encoderinfo.get("palette", im.info.get("palette")) palette = im.encoderinfo.get("palette", im.info.get("palette"))
@ -730,7 +756,7 @@ def _save(im, fp, filename, save_all=False):
fp.flush() fp.flush()
def get_interlace(im): def get_interlace(im: Image.Image) -> int:
interlace = im.encoderinfo.get("interlace", 1) interlace = im.encoderinfo.get("interlace", 1)
# workaround for @PIL153 # workaround for @PIL153
@ -740,7 +766,9 @@ def get_interlace(im):
return interlace return interlace
def _write_local_header(fp, im, offset, flags): def _write_local_header(
fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int
) -> None:
try: try:
transparency = im.encoderinfo["transparency"] transparency = im.encoderinfo["transparency"]
except KeyError: except KeyError:
@ -788,7 +816,7 @@ def _write_local_header(fp, im, offset, flags):
fp.write(o8(8)) # bits fp.write(o8(8)) # bits
def _save_netpbm(im, fp, filename): def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
# Unused by default. # Unused by default.
# To use, uncomment the register_save call at the end of the file. # To use, uncomment the register_save call at the end of the file.
# #
@ -819,6 +847,7 @@ def _save_netpbm(im, fp, filename):
) )
# Allow ppmquant to receive SIGPIPE if ppmtogif exits # Allow ppmquant to receive SIGPIPE if ppmtogif exits
assert quant_proc.stdout is not None
quant_proc.stdout.close() quant_proc.stdout.close()
retcode = quant_proc.wait() retcode = quant_proc.wait()
@ -840,7 +869,7 @@ def _save_netpbm(im, fp, filename):
_FORCE_OPTIMIZE = False _FORCE_OPTIMIZE = False
def _get_optimize(im, info): def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
""" """
Palette optimization is a potentially expensive operation. Palette optimization is a potentially expensive operation.
@ -884,6 +913,7 @@ def _get_optimize(im, info):
and current_palette_size > 2 and current_palette_size > 2
): ):
return used_palette_colors return used_palette_colors
return None
def _get_color_table_size(palette_bytes: bytes) -> int: def _get_color_table_size(palette_bytes: bytes) -> int:
@ -924,7 +954,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes:
return im.palette.palette if im.palette else b"" return im.palette.palette if im.palette else b""
def _get_background(im, info_background): def _get_background(
im: Image.Image,
info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None,
) -> int:
background = 0 background = 0
if info_background: if info_background:
if isinstance(info_background, tuple): if isinstance(info_background, tuple):
@ -947,7 +980,7 @@ def _get_background(im, info_background):
return background return background
def _get_global_header(im, info): def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]:
"""Return a list of strings representing a GIF header""" """Return a list of strings representing a GIF header"""
# Header Block # Header Block
@ -1009,7 +1042,12 @@ def _get_global_header(im, info):
return header return header
def _write_frame_data(fp, im_frame, offset, params): def _write_frame_data(
fp: IO[bytes],
im_frame: Image.Image,
offset: tuple[int, int],
params: dict[str, Any],
) -> None:
try: try:
im_frame.encoderinfo = params im_frame.encoderinfo = params
@ -1029,7 +1067,9 @@ def _write_frame_data(fp, im_frame, offset, params):
# Legacy GIF utilities # Legacy GIF utilities
def getheader(im, palette=None, info=None): def getheader(
im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None
) -> tuple[list[bytes], list[int] | None]:
""" """
Legacy Method to get Gif data from image. Legacy Method to get Gif data from image.
@ -1041,11 +1081,11 @@ def getheader(im, palette=None, info=None):
:returns: tuple of(list of header items, optimized palette) :returns: tuple of(list of header items, optimized palette)
""" """
used_palette_colors = _get_optimize(im, info)
if info is None: if info is None:
info = {} info = {}
used_palette_colors = _get_optimize(im, info)
if "background" not in info and "background" in im.info: if "background" not in info and "background" in im.info:
info["background"] = im.info["background"] info["background"] = im.info["background"]
@ -1057,7 +1097,9 @@ def getheader(im, palette=None, info=None):
return header, used_palette_colors return header, used_palette_colors
def getdata(im, offset=(0, 0), **params): def getdata(
im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
) -> list[bytes]:
""" """
Legacy Method Legacy Method
@ -1074,12 +1116,23 @@ def getdata(im, offset=(0, 0), **params):
:returns: List of bytes containing GIF encoded frame data :returns: List of bytes containing GIF encoded frame data
""" """
from io import BytesIO
class Collector: class Collector(BytesIO):
data = [] data = []
def write(self, data): if sys.version_info >= (3, 12):
self.data.append(data) from collections.abc import Buffer
def write(self, data: Buffer) -> int:
self.data.append(data)
return len(data)
else:
def write(self, data: Any) -> int:
self.data.append(data)
return len(data)
im.load() # make sure raster data is available im.load() # make sure raster data is available

View File

@ -21,6 +21,7 @@ See the GIMP distribution for more information.)
from __future__ import annotations from __future__ import annotations
from math import log, pi, sin, sqrt from math import log, pi, sin, sqrt
from typing import IO, Callable
from ._binary import o8 from ._binary import o8
@ -28,7 +29,7 @@ EPSILON = 1e-10
"""""" # Enable auto-doc for data member """""" # Enable auto-doc for data member
def linear(middle, pos): def linear(middle: float, pos: float) -> float:
if pos <= middle: if pos <= middle:
if middle < EPSILON: if middle < EPSILON:
return 0.0 return 0.0
@ -43,19 +44,19 @@ def linear(middle, pos):
return 0.5 + 0.5 * pos / middle return 0.5 + 0.5 * pos / middle
def curved(middle, pos): def curved(middle: float, pos: float) -> float:
return pos ** (log(0.5) / log(max(middle, EPSILON))) return pos ** (log(0.5) / log(max(middle, EPSILON)))
def sine(middle, pos): def sine(middle: float, pos: float) -> float:
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
def sphere_increasing(middle, pos): def sphere_increasing(middle: float, pos: float) -> float:
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
def sphere_decreasing(middle, pos): def sphere_decreasing(middle: float, pos: float) -> float:
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
@ -64,9 +65,22 @@ SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
class GradientFile: class GradientFile:
gradient = None gradient: (
list[
tuple[
float,
float,
float,
list[float],
list[float],
Callable[[float, float], float],
]
]
| None
) = None
def getpalette(self, entries=256): def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
assert self.gradient is not None
palette = [] palette = []
ix = 0 ix = 0
@ -101,7 +115,7 @@ class GradientFile:
class GimpGradientFile(GradientFile): class GimpGradientFile(GradientFile):
"""File handler for GIMP's gradient format.""" """File handler for GIMP's gradient format."""
def __init__(self, fp): def __init__(self, fp: IO[bytes]) -> None:
if fp.readline()[:13] != b"GIMP Gradient": if fp.readline()[:13] != b"GIMP Gradient":
msg = "not a GIMP gradient file" msg = "not a GIMP gradient file"
raise SyntaxError(msg) raise SyntaxError(msg)
@ -114,7 +128,7 @@ class GimpGradientFile(GradientFile):
count = int(line) count = int(line)
gradient = [] self.gradient = []
for i in range(count): for i in range(count):
s = fp.readline().split() s = fp.readline().split()
@ -132,6 +146,4 @@ class GimpGradientFile(GradientFile):
msg = "cannot handle HSV colour space" msg = "cannot handle HSV colour space"
raise OSError(msg) raise OSError(msg)
gradient.append((x0, x1, xm, rgb0, rgb1, segment)) self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))
self.gradient = gradient

View File

@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import IO
from ._binary import o8 from ._binary import o8
@ -25,8 +26,8 @@ class GimpPaletteFile:
rawmode = "RGB" rawmode = "RGB"
def __init__(self, fp): def __init__(self, fp: IO[bytes]) -> None:
self.palette = [o8(i) * 3 for i in range(256)] palette = [o8(i) * 3 for i in range(256)]
if fp.readline()[:12] != b"GIMP Palette": if fp.readline()[:12] != b"GIMP Palette":
msg = "not a GIMP palette file" msg = "not a GIMP palette file"
@ -49,9 +50,9 @@ class GimpPaletteFile:
msg = "bad palette entry" msg = "bad palette entry"
raise ValueError(msg) raise ValueError(msg)
self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2])
self.palette = b"".join(self.palette) self.palette = b"".join(palette)
def getpalette(self) -> tuple[bytes, str]: def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode return self.palette, self.rawmode

View File

@ -10,12 +10,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
_handler = None _handler = None
def register_handler(handler): def register_handler(handler: ImageFile.StubHandler | None) -> None:
""" """
Install application-specific GRIB image handler. Install application-specific GRIB image handler.
@ -54,11 +56,11 @@ class GribStubImageFile(ImageFile.StubImageFile):
if loader: if loader:
loader.open(self) loader.open(self)
def _load(self): def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"): if _handler is None or not hasattr(_handler, "save"):
msg = "GRIB save handler not installed" msg = "GRIB save handler not installed"
raise OSError(msg) raise OSError(msg)

View File

@ -10,12 +10,14 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import IO
from . import Image, ImageFile from . import Image, ImageFile
_handler = None _handler = None
def register_handler(handler): def register_handler(handler: ImageFile.StubHandler | None) -> None:
""" """
Install application-specific HDF5 image handler. Install application-specific HDF5 image handler.
@ -54,11 +56,11 @@ class HDF5StubImageFile(ImageFile.StubImageFile):
if loader: if loader:
loader.open(self) loader.open(self)
def _load(self): def _load(self) -> ImageFile.StubHandler | None:
return _handler return _handler
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"): if _handler is None or not hasattr(_handler, "save"):
msg = "HDF5 save handler not installed" msg = "HDF5 save handler not installed"
raise OSError(msg) raise OSError(msg)

View File

@ -22,6 +22,7 @@ import io
import os import os
import struct import struct
import sys import sys
from typing import IO
from . import Image, ImageFile, PngImagePlugin, features from . import Image, ImageFile, PngImagePlugin, features
@ -312,7 +313,7 @@ class IcnsImageFile(ImageFile.ImageFile):
return px return px
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
""" """
Saves the image as a series of PNG files, Saves the image as a series of PNG files,
that are then combined into a .icns file. that are then combined into a .icns file.
@ -346,29 +347,27 @@ def _save(im, fp, filename):
entries = [] entries = []
for type, size in sizes.items(): for type, size in sizes.items():
stream = size_streams[size] stream = size_streams[size]
entries.append( entries.append((type, HEADERSIZE + len(stream), stream))
{"type": type, "size": HEADERSIZE + len(stream), "stream": stream}
)
# Header # Header
fp.write(MAGIC) fp.write(MAGIC)
file_length = HEADERSIZE # Header file_length = HEADERSIZE # Header
file_length += HEADERSIZE + 8 * len(entries) # TOC file_length += HEADERSIZE + 8 * len(entries) # TOC
file_length += sum(entry["size"] for entry in entries) file_length += sum(entry[1] for entry in entries)
fp.write(struct.pack(">i", file_length)) fp.write(struct.pack(">i", file_length))
# TOC # TOC
fp.write(b"TOC ") fp.write(b"TOC ")
fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE))
for entry in entries: for entry in entries:
fp.write(entry["type"]) fp.write(entry[0])
fp.write(struct.pack(">i", entry["size"])) fp.write(struct.pack(">i", entry[1]))
# Data # Data
for entry in entries: for entry in entries:
fp.write(entry["type"]) fp.write(entry[0])
fp.write(struct.pack(">i", entry["size"])) fp.write(struct.pack(">i", entry[1]))
fp.write(entry["stream"]) fp.write(entry[2])
if hasattr(fp, "flush"): if hasattr(fp, "flush"):
fp.flush() fp.flush()

View File

@ -25,6 +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 . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -39,7 +40,7 @@ from ._binary import o32le as o32
_MAGIC = b"\0\0\1\0" _MAGIC = b"\0\0\1\0"
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
fp.write(_MAGIC) # (2+2) fp.write(_MAGIC) # (2+2)
bmp = im.encoderinfo.get("bitmap_format") == "bmp" bmp = im.encoderinfo.get("bitmap_format") == "bmp"
sizes = im.encoderinfo.get( sizes = im.encoderinfo.get(
@ -194,7 +195,7 @@ class IcoFile:
""" """
return self.frame(self.getentryindex(size, bpp)) return self.frame(self.getentryindex(size, bpp))
def frame(self, idx): def frame(self, idx: int) -> Image.Image:
""" """
Get an image from frame idx Get an image from frame idx
""" """
@ -205,6 +206,7 @@ class IcoFile:
data = self.buf.read(8) data = self.buf.read(8)
self.buf.seek(header["offset"]) self.buf.seek(header["offset"])
im: Image.Image
if data[:8] == PngImagePlugin._MAGIC: if data[:8] == PngImagePlugin._MAGIC:
# png frame # png frame
im = PngImagePlugin.PngImageFile(self.buf) im = PngImagePlugin.PngImageFile(self.buf)

View File

@ -28,6 +28,7 @@ from __future__ import annotations
import os import os
import re import re
from typing import IO, Any
from . import Image, ImageFile, ImagePalette from . import Image, ImageFile, ImagePalette
@ -103,7 +104,7 @@ for j in range(2, 33):
split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$")
def number(s): def number(s: Any) -> float:
try: try:
return int(s) return int(s)
except ValueError: except ValueError:
@ -325,7 +326,7 @@ SAVE = {
} }
def _save(im, fp, filename): def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
try: try:
image_type, rawmode = SAVE[im.mode] image_type, rawmode = SAVE[im.mode]
except KeyError as e: except KeyError as e:
@ -340,6 +341,8 @@ def _save(im, fp, filename):
# or: SyntaxError("not an IM file") # or: SyntaxError("not an IM file")
# 8 characters are used for "Name: " and "\r\n" # 8 characters are used for "Name: " and "\r\n"
# Keep just the filename, ditch the potentially overlong path # Keep just the filename, ditch the potentially overlong path
if isinstance(filename, bytes):
filename = filename.decode("ascii")
name, ext = os.path.splitext(os.path.basename(filename)) name, ext = os.path.splitext(os.path.basename(filename))
name = "".join([name[: 92 - len(ext)], ext]) name = "".join([name[: 92 - len(ext)], ext])

View File

@ -41,7 +41,7 @@ import warnings
from collections.abc import Callable, MutableMapping from collections.abc import Callable, MutableMapping
from enum import IntEnum from enum import IntEnum
from types import ModuleType from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast
# VERSION was removed in Pillow 6.0.0. # VERSION was removed in Pillow 6.0.0.
# PILLOW_VERSION was removed in Pillow 9.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0.
@ -410,7 +410,9 @@ def init() -> bool:
# Codec factories (used by tobytes/frombytes and ImageFile.load) # Codec factories (used by tobytes/frombytes and ImageFile.load)
def _getdecoder(mode, decoder_name, args, extra=()): def _getdecoder(
mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = ()
) -> core.ImagingDecoder | ImageFile.PyDecoder:
# tweak arguments # tweak arguments
if args is None: if args is None:
args = () args = ()
@ -433,7 +435,9 @@ def _getdecoder(mode, decoder_name, args, extra=()):
return decoder(mode, *args + extra) return decoder(mode, *args + extra)
def _getencoder(mode, encoder_name, args, extra=()): def _getencoder(
mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = ()
) -> core.ImagingEncoder | ImageFile.PyEncoder:
# tweak arguments # tweak arguments
if args is None: if args is None:
args = () args = ()
@ -503,6 +507,12 @@ def _getscaleoffset(expr):
# Implementation wrapper # Implementation wrapper
class SupportsGetData(Protocol):
def getdata(
self,
) -> tuple[Transform, Sequence[int]]: ...
class Image: class Image:
""" """
This class represents an image object. To create This class represents an image object. To create
@ -544,10 +554,10 @@ class Image:
return self._size return self._size
@property @property
def mode(self): def mode(self) -> str:
return self._mode return self._mode
def _new(self, im) -> Image: def _new(self, im: core.ImagingCore) -> Image:
new = Image() new = Image()
new.im = im new.im = im
new._mode = im.mode new._mode = im.mode
@ -620,7 +630,7 @@ class Image:
self.load() self.load()
def _dump( def _dump(
self, file: str | None = None, format: str | None = None, **options self, file: str | None = None, format: str | None = None, **options: Any
) -> str: ) -> str:
suffix = "" suffix = ""
if format: if format:
@ -643,10 +653,12 @@ class Image:
return filename return filename
def __eq__(self, other): def __eq__(self, other: object) -> bool:
if self.__class__ is not other.__class__:
return False
assert isinstance(other, Image)
return ( return (
self.__class__ is other.__class__ self.mode == other.mode
and self.mode == other.mode
and self.size == other.size and self.size == other.size
and self.info == other.info and self.info == other.info
and self.getpalette() == other.getpalette() and self.getpalette() == other.getpalette()
@ -679,7 +691,7 @@ class Image:
) )
) )
def _repr_image(self, image_format, **kwargs): def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None:
"""Helper function for iPython display hook. """Helper function for iPython display hook.
:param image_format: Image format. :param image_format: Image format.
@ -692,14 +704,14 @@ class Image:
return None return None
return b.getvalue() return b.getvalue()
def _repr_png_(self): def _repr_png_(self) -> bytes | None:
"""iPython display hook support for PNG format. """iPython display hook support for PNG format.
:returns: PNG version of the image as bytes :returns: PNG version of the image as bytes
""" """
return self._repr_image("PNG", compress_level=1) return self._repr_image("PNG", compress_level=1)
def _repr_jpeg_(self): def _repr_jpeg_(self) -> bytes | None:
"""iPython display hook support for JPEG format. """iPython display hook support for JPEG format.
:returns: JPEG version of the image as bytes :returns: JPEG version of the image as bytes
@ -746,7 +758,7 @@ class Image:
self.putpalette(palette) self.putpalette(palette)
self.frombytes(data) self.frombytes(data)
def tobytes(self, encoder_name: str = "raw", *args) -> bytes: def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes:
""" """
Return image as a bytes object. Return image as a bytes object.
@ -768,12 +780,13 @@ class Image:
:returns: A :py:class:`bytes` object. :returns: A :py:class:`bytes` object.
""" """
# may pass tuple instead of argument list encoder_args: Any = args
if len(args) == 1 and isinstance(args[0], tuple): if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple):
args = args[0] # may pass tuple instead of argument list
encoder_args = encoder_args[0]
if encoder_name == "raw" and args == (): if encoder_name == "raw" and encoder_args == ():
args = self.mode encoder_args = self.mode
self.load() self.load()
@ -781,7 +794,7 @@ class Image:
return b"" return b""
# unpack data # unpack data
e = _getencoder(self.mode, encoder_name, args) e = _getencoder(self.mode, encoder_name, encoder_args)
e.setimage(self.im) e.setimage(self.im)
bufsize = max(65536, self.size[0] * 4) # see RawEncode.c bufsize = max(65536, self.size[0] * 4) # see RawEncode.c
@ -824,7 +837,9 @@ class Image:
] ]
) )
def frombytes(self, data: bytes, decoder_name: str = "raw", *args) -> None: def frombytes(
self, data: bytes | bytearray, decoder_name: str = "raw", *args: Any
) -> None:
""" """
Loads this image with pixel data from a bytes object. Loads this image with pixel data from a bytes object.
@ -835,16 +850,17 @@ class Image:
if self.width == 0 or self.height == 0: if self.width == 0 or self.height == 0:
return return
# may pass tuple instead of argument list decoder_args: Any = args
if len(args) == 1 and isinstance(args[0], tuple): if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple):
args = args[0] # may pass tuple instead of argument list
decoder_args = decoder_args[0]
# default format # default format
if decoder_name == "raw" and args == (): if decoder_name == "raw" and decoder_args == ():
args = self.mode decoder_args = self.mode
# unpack data # unpack data
d = _getdecoder(self.mode, decoder_name, args) d = _getdecoder(self.mode, decoder_name, decoder_args)
d.setimage(self.im) d.setimage(self.im)
s = d.decode(data) s = d.decode(data)
@ -873,7 +889,7 @@ class Image:
if self.im is not None and self.palette and self.palette.dirty: if self.im is not None and self.palette and self.palette.dirty:
# realize palette # realize palette
mode, arr = self.palette.getdata() mode, arr = self.palette.getdata()
self.im.putpalette(mode, arr) self.im.putpalette(self.palette.mode, mode, arr)
self.palette.dirty = 0 self.palette.dirty = 0
self.palette.rawmode = None self.palette.rawmode = None
if "transparency" in self.info and mode in ("LA", "PA"): if "transparency" in self.info and mode in ("LA", "PA"):
@ -883,9 +899,9 @@ class Image:
self.im.putpalettealphas(self.info["transparency"]) self.im.putpalettealphas(self.info["transparency"])
self.palette.mode = "RGBA" self.palette.mode = "RGBA"
else: else:
palette_mode = "RGBA" if mode.startswith("RGBA") else "RGB" self.palette.palette = self.im.getpalette(
self.palette.mode = palette_mode self.palette.mode, self.palette.mode
self.palette.palette = self.im.getpalette(palette_mode, palette_mode) )
if self.im is not None: if self.im is not None:
if cffi and USE_CFFI_ACCESS: if cffi and USE_CFFI_ACCESS:
@ -988,9 +1004,11 @@ class Image:
if has_transparency and self.im.bands == 3: if has_transparency and self.im.bands == 3:
transparency = new_im.info["transparency"] transparency = new_im.info["transparency"]
def convert_transparency(m, v): def convert_transparency(
v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 m: tuple[float, ...], v: tuple[int, int, int]
return max(0, min(255, int(v))) ) -> int:
value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5
return max(0, min(255, int(value)))
if mode == "L": if mode == "L":
transparency = convert_transparency(matrix, transparency) transparency = convert_transparency(matrix, transparency)
@ -1150,7 +1168,7 @@ class Image:
def quantize( def quantize(
self, self,
colors: int = 256, colors: int = 256,
method: Quantize | None = None, method: int | None = None,
kmeans: int = 0, kmeans: int = 0,
palette=None, palette=None,
dither: Dither = Dither.FLOYDSTEINBERG, dither: Dither = Dither.FLOYDSTEINBERG,
@ -1242,7 +1260,7 @@ class Image:
__copy__ = copy __copy__ = copy
def crop(self, box: tuple[int, int, int, int] | None = None) -> Image: def crop(self, box: tuple[float, float, float, float] | None = None) -> Image:
""" """
Returns a rectangular region from this image. The box is a Returns a rectangular region from this image. The box is a
4-tuple defining the left, upper, right, and lower pixel 4-tuple defining the left, upper, right, and lower pixel
@ -1268,7 +1286,9 @@ class Image:
self.load() self.load()
return self._new(self._crop(self.im, box)) return self._new(self._crop(self.im, box))
def _crop(self, im, box): def _crop(
self, im: core.ImagingCore, box: tuple[float, float, float, float]
) -> core.ImagingCore:
""" """
Returns a rectangular region from the core image object im. Returns a rectangular region from the core image object im.
@ -1289,7 +1309,7 @@ class Image:
return im.crop((x0, y0, x1, y1)) return im.crop((x0, y0, x1, y1))
def draft( def draft(
self, mode: str, size: tuple[int, int] self, mode: str | None, size: tuple[int, int] | None
) -> tuple[str, tuple[int, int, float, float]] | None: ) -> tuple[str, tuple[int, int, float, float]] | None:
""" """
Configures the image file loader so it returns a version of the Configures the image file loader so it returns a version of the
@ -1359,7 +1379,7 @@ class Image:
""" """
return ImageMode.getmode(self.mode).bands return ImageMode.getmode(self.mode).bands
def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None:
""" """
Calculates the bounding box of the non-zero regions in the Calculates the bounding box of the non-zero regions in the
image. image.
@ -1439,8 +1459,15 @@ class Image:
return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands))
return self.im.getextrema() return self.im.getextrema()
def _getxmp(self, xmp_tags): def getxmp(self):
def get_name(tag): """
Returns a dictionary containing the XMP tags.
Requires defusedxml to be installed.
:returns: XMP tags in a dictionary.
"""
def get_name(tag: str) -> str:
return re.sub("^{[^}]+}", "", tag) return re.sub("^{[^}]+}", "", tag)
def get_value(element): def get_value(element):
@ -1466,9 +1493,10 @@ class Image:
if ElementTree is None: if ElementTree is None:
warnings.warn("XMP data cannot be read without defusedxml dependency") warnings.warn("XMP data cannot be read without defusedxml dependency")
return {} return {}
else: if "xmp" not in self.info:
root = ElementTree.fromstring(xmp_tags) return {}
return {get_name(root.tag): get_value(root)} root = ElementTree.fromstring(self.info["xmp"])
return {get_name(root.tag): get_value(root)}
def getexif(self) -> Exif: def getexif(self) -> Exif:
""" """
@ -1511,7 +1539,7 @@ class Image:
self._exif._loaded = False self._exif._loaded = False
self.getexif() self.getexif()
def get_child_images(self): def get_child_images(self) -> list[ImageFile.ImageFile]:
child_images = [] child_images = []
exif = self.getexif() exif = self.getexif()
ifds = [] ifds = []
@ -1535,16 +1563,17 @@ class Image:
fp = self.fp fp = self.fp
thumbnail_offset = ifd.get(513) thumbnail_offset = ifd.get(513)
if thumbnail_offset is not None: if thumbnail_offset is not None:
try: thumbnail_offset += getattr(self, "_exif_offset", 0)
thumbnail_offset += self._exif_offset
except AttributeError:
pass
self.fp.seek(thumbnail_offset) self.fp.seek(thumbnail_offset)
data = self.fp.read(ifd.get(514)) data = self.fp.read(ifd.get(514))
fp = io.BytesIO(data) fp = io.BytesIO(data)
with open(fp) as im: with open(fp) as im:
if thumbnail_offset is None: from . import TiffImagePlugin
if thumbnail_offset is None and isinstance(
im, TiffImagePlugin.TiffImageFile
):
im._frame_pos = [ifd_offset] im._frame_pos = [ifd_offset]
im._seek(0) im._seek(0)
im.load() im.load()
@ -1604,7 +1633,7 @@ class Image:
or "transparency" in self.info or "transparency" in self.info
) )
def apply_transparency(self): def apply_transparency(self) -> None:
""" """
If a P mode image has a "transparency" key in the info dictionary, If a P mode image has a "transparency" key in the info dictionary,
remove the key and instead apply the transparency to the palette. remove the key and instead apply the transparency to the palette.
@ -1616,6 +1645,7 @@ class Image:
from . import ImagePalette from . import ImagePalette
palette = self.getpalette("RGBA") palette = self.getpalette("RGBA")
assert palette is not None
transparency = self.info["transparency"] transparency = self.info["transparency"]
if isinstance(transparency, bytes): if isinstance(transparency, bytes):
for i, alpha in enumerate(transparency): for i, alpha in enumerate(transparency):
@ -1711,7 +1741,12 @@ class Image:
return self.im.entropy(extrema) return self.im.entropy(extrema)
return self.im.entropy() return self.im.entropy()
def paste(self, im, box=None, mask=None) -> None: def paste(
self,
im: Image | str | float | tuple[float, ...],
box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None,
mask: Image | None = None,
) -> None:
""" """
Pastes another image into this image. The box argument is either Pastes another image into this image. The box argument is either
a 2-tuple giving the upper left corner, a 4-tuple defining the a 2-tuple giving the upper left corner, a 4-tuple defining the
@ -1739,7 +1774,7 @@ class Image:
See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to
combine images with respect to their alpha channels. combine images with respect to their alpha channels.
:param im: Source image or pixel value (integer or tuple). :param im: Source image or pixel value (integer, float or tuple).
:param box: An optional 4-tuple giving the region to paste into. :param box: An optional 4-tuple giving the region to paste into.
If a 2-tuple is used instead, it's treated as the upper left If a 2-tuple is used instead, it's treated as the upper left
corner. If omitted or None, the source is pasted into the corner. If omitted or None, the source is pasted into the
@ -1751,10 +1786,14 @@ class Image:
:param mask: An optional mask image. :param mask: An optional mask image.
""" """
if isImageType(box) and mask is None: if isImageType(box):
if mask is not None:
msg = "If using second argument as mask, third argument must be None"
raise ValueError(msg)
# abbreviated paste(im, mask) syntax # abbreviated paste(im, mask) syntax
mask = box mask = box
box = None box = None
assert not isinstance(box, Image)
if box is None: if box is None:
box = (0, 0) box = (0, 0)
@ -1792,7 +1831,9 @@ class Image:
else: else:
self.im.paste(im, box) self.im.paste(im, box)
def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): def alpha_composite(
self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0)
) -> None:
"""'In-place' analog of Image.alpha_composite. Composites an image """'In-place' analog of Image.alpha_composite. Composites an image
onto this image. onto this image.
@ -1807,32 +1848,35 @@ class Image:
""" """
if not isinstance(source, (list, tuple)): if not isinstance(source, (list, tuple)):
msg = "Source must be a tuple" msg = "Source must be a list or tuple"
raise ValueError(msg) raise ValueError(msg)
if not isinstance(dest, (list, tuple)): if not isinstance(dest, (list, tuple)):
msg = "Destination must be a tuple" msg = "Destination must be a list or tuple"
raise ValueError(msg) raise ValueError(msg)
if len(source) not in (2, 4):
msg = "Source must be a 2 or 4-tuple" if len(source) == 4:
overlay_crop_box = tuple(source)
elif len(source) == 2:
overlay_crop_box = tuple(source) + im.size
else:
msg = "Source must be a sequence of length 2 or 4"
raise ValueError(msg) raise ValueError(msg)
if not len(dest) == 2: if not len(dest) == 2:
msg = "Destination must be a 2-tuple" msg = "Destination must be a sequence of length 2"
raise ValueError(msg) raise ValueError(msg)
if min(source) < 0: if min(source) < 0:
msg = "Source must be non-negative" msg = "Source must be non-negative"
raise ValueError(msg) raise ValueError(msg)
if len(source) == 2: # over image, crop if it's not the whole image.
source = source + im.size if overlay_crop_box == (0, 0) + im.size:
# over image, crop if it's not the whole thing.
if source == (0, 0) + im.size:
overlay = im overlay = im
else: else:
overlay = im.crop(source) overlay = im.crop(overlay_crop_box)
# target for the paste # target for the paste
box = dest + (dest[0] + overlay.width, dest[1] + overlay.height) box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height)
# destination image. don't copy if we're using the whole image. # destination image. don't copy if we're using the whole image.
if box == (0, 0) + self.size: if box == (0, 0) + self.size:
@ -1843,7 +1887,11 @@ class Image:
result = alpha_composite(background, overlay) result = alpha_composite(background, overlay)
self.paste(result, box) self.paste(result, box)
def point(self, lut, mode: str | None = None) -> Image: def point(
self,
lut: Sequence[float] | Callable[[int], float] | ImagePointHandler,
mode: str | None = None,
) -> Image:
""" """
Maps this image through a lookup table or function. Maps this image through a lookup table or function.
@ -1880,7 +1928,9 @@ class Image:
scale, offset = _getscaleoffset(lut) scale, offset = _getscaleoffset(lut)
return self._new(self.im.point_transform(scale, offset)) return self._new(self.im.point_transform(scale, offset))
# for other modes, convert the function to a table # for other modes, convert the function to a table
lut = [lut(i) for i in range(256)] * self.im.bands flatLut = [lut(i) for i in range(256)] * self.im.bands
else:
flatLut = lut
if self.mode == "F": if self.mode == "F":
# FIXME: _imaging returns a confusing error message for this case # FIXME: _imaging returns a confusing error message for this case
@ -1888,8 +1938,8 @@ class Image:
raise ValueError(msg) raise ValueError(msg)
if mode != "F": if mode != "F":
lut = [round(i) for i in lut] flatLut = [round(i) for i in flatLut]
return self._new(self.im.point(lut, mode)) return self._new(self.im.point(flatLut, mode))
def putalpha(self, alpha): def putalpha(self, alpha):
""" """
@ -1948,7 +1998,12 @@ class Image:
self.im.putband(alpha.im, band) self.im.putband(alpha.im, band)
def putdata(self, data, scale=1.0, offset=0.0): def putdata(
self,
data: Sequence[float] | Sequence[Sequence[int]],
scale: float = 1.0,
offset: float = 0.0,
) -> None:
""" """
Copies pixel data from a flattened sequence object into the image. The Copies pixel data from a flattened sequence object into the image. The
values should start at the upper left corner (0, 0), continue to the values should start at the upper left corner (0, 0), continue to the
@ -1998,7 +2053,7 @@ class Image:
palette = ImagePalette.raw(rawmode, data) palette = ImagePalette.raw(rawmode, data)
self._mode = "PA" if "A" in self.mode else "P" self._mode = "PA" if "A" in self.mode else "P"
self.palette = palette self.palette = palette
self.palette.mode = "RGB" self.palette.mode = "RGBA" if "A" in rawmode else "RGB"
self.load() # install new palette self.load() # install new palette
def putpixel(self, xy, value): def putpixel(self, xy, value):
@ -2113,7 +2168,7 @@ class Image:
# m_im.putpalette(mapping_palette, 'L') # converts to 'P' # m_im.putpalette(mapping_palette, 'L') # converts to 'P'
# or just force it. # or just force it.
# UNDONE -- this is part of the general issue with palettes # UNDONE -- this is part of the general issue with palettes
m_im.im.putpalette(palette_mode + ";L", m_im.palette.tobytes()) m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes())
m_im = m_im.convert("L") m_im = m_im.convert("L")
@ -2146,7 +2201,13 @@ class Image:
min(self.size[1], math.ceil(box[3] + support_y)), min(self.size[1], math.ceil(box[3] + support_y)),
) )
def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image: def resize(
self,
size: tuple[int, int],
resample: int | None = None,
box: tuple[float, float, float, float] | None = None,
reducing_gap: float | None = None,
) -> Image:
""" """
Returns a resized copy of this image. Returns a resized copy of this image.
@ -2211,13 +2272,9 @@ class Image:
msg = "reducing_gap must be 1.0 or greater" msg = "reducing_gap must be 1.0 or greater"
raise ValueError(msg) raise ValueError(msg)
size = tuple(size)
self.load() self.load()
if box is None: if box is None:
box = (0, 0) + self.size box = (0, 0) + self.size
else:
box = tuple(box)
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()
@ -2252,7 +2309,11 @@ class Image:
return self._new(self.im.resize(size, resample, box)) return self._new(self.im.resize(size, resample, box))
def reduce(self, factor, box=None): def reduce(
self,
factor: int | tuple[int, int],
box: tuple[int, int, int, int] | None = None,
) -> Image:
""" """
Returns a copy of the image reduced ``factor`` times. Returns a copy of the image reduced ``factor`` times.
If the size of the image is not dividable by ``factor``, If the size of the image is not dividable by ``factor``,
@ -2270,8 +2331,6 @@ class Image:
if box is None: if box is None:
box = (0, 0) + self.size box = (0, 0) + self.size
else:
box = tuple(box)
if factor == (1, 1) and box == (0, 0) + self.size: if factor == (1, 1) and box == (0, 0) + self.size:
return self.copy() return self.copy()
@ -2287,13 +2346,13 @@ class Image:
def rotate( def rotate(
self, self,
angle, angle: float,
resample=Resampling.NEAREST, resample: Resampling = Resampling.NEAREST,
expand=0, expand: int | bool = False,
center=None, center: tuple[int, int] | None = None,
translate=None, translate: tuple[int, int] | None = None,
fillcolor=None, fillcolor: float | tuple[float, ...] | str | None = None,
): ) -> Image:
""" """
Returns a rotated copy of this image. This method returns a Returns a rotated copy of this image. This method returns a
copy of this image, rotated the given number of degrees counter copy of this image, rotated the given number of degrees counter
@ -2455,7 +2514,7 @@ class Image:
save_all = params.pop("save_all", False) save_all = params.pop("save_all", False)
self.encoderinfo = params self.encoderinfo = params
self.encoderconfig = () self.encoderconfig: tuple[Any, ...] = ()
preinit() preinit()
@ -2600,7 +2659,12 @@ class Image:
""" """
return 0 return 0
def thumbnail(self, size, resample=Resampling.BICUBIC, reducing_gap=2.0): def thumbnail(
self,
size: tuple[float, float],
resample: Resampling = Resampling.BICUBIC,
reducing_gap: float | None = 2.0,
) -> None:
""" """
Make this image into a thumbnail. This method modifies the Make this image into a thumbnail. This method modifies the
image to contain a thumbnail version of itself, no larger than image to contain a thumbnail version of itself, no larger than
@ -2660,27 +2724,32 @@ class Image:
return x, y return x, y
box = None box = None
final_size: tuple[int, int]
if reducing_gap is not None: if reducing_gap is not None:
size = preserve_aspect_ratio() preserved_size = preserve_aspect_ratio()
if size is None: if preserved_size is None:
return return
final_size = preserved_size
res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) res = self.draft(
None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap))
)
if res is not None: if res is not None:
box = res[1] box = res[1]
if box is None: if box is None:
self.load() self.load()
# load() may have changed the size of the image # load() may have changed the size of the image
size = preserve_aspect_ratio() preserved_size = preserve_aspect_ratio()
if size is None: if preserved_size is None:
return return
final_size = preserved_size
if self.size != size: if self.size != final_size:
im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap)
self.im = im.im self.im = im.im
self._size = size self._size = final_size
self._mode = self.im.mode self._mode = self.im.mode
self.readonly = 0 self.readonly = 0
@ -2690,12 +2759,12 @@ class Image:
# instead of bloating the method docs, add a separate chapter. # instead of bloating the method docs, add a separate chapter.
def transform( def transform(
self, self,
size, size: tuple[int, int],
method, method: Transform | ImageTransformHandler | SupportsGetData,
data=None, data: Sequence[Any] | None = None,
resample=Resampling.NEAREST, resample: int = Resampling.NEAREST,
fill=1, fill: int = 1,
fillcolor=None, fillcolor: float | tuple[float, ...] | str | None = None,
) -> Image: ) -> Image:
""" """
Transforms this image. This method creates a new image with the Transforms this image. This method creates a new image with the
@ -2859,7 +2928,7 @@ class Image:
if image.mode in ("1", "P"): if image.mode in ("1", "P"):
resample = Resampling.NEAREST resample = Resampling.NEAREST
self.im.transform2(box, image.im, method, data, resample, fill) self.im.transform(box, image.im, method, data, resample, fill)
def transpose(self, method: Transpose) -> Image: def transpose(self, method: Transpose) -> Image:
""" """
@ -2875,7 +2944,7 @@ class Image:
self.load() self.load()
return self._new(self.im.transpose(method)) return self._new(self.im.transpose(method))
def effect_spread(self, distance): def effect_spread(self, distance: int) -> Image:
""" """
Randomly spread pixels in an image. Randomly spread pixels in an image.
@ -2929,7 +2998,7 @@ class ImageTransformHandler:
self, self,
size: tuple[int, int], size: tuple[int, int],
image: Image, image: Image,
**options: dict[str, str | int | tuple[int, ...] | list[int]], **options: Any,
) -> Image: ) -> Image:
pass pass
@ -2941,35 +3010,35 @@ class ImageTransformHandler:
# Debugging # Debugging
def _wedge(): def _wedge() -> Image:
"""Create grayscale wedge (for debugging only)""" """Create grayscale wedge (for debugging only)"""
return Image()._new(core.wedge("L")) return Image()._new(core.wedge("L"))
def _check_size(size): def _check_size(size: Any) -> None:
""" """
Common check to enforce type and sanity check on size tuples Common check to enforce type and sanity check on size tuples
:param size: Should be a 2 tuple of (width, height) :param size: Should be a 2 tuple of (width, height)
:returns: True, or raises a ValueError :returns: None, or raises a ValueError
""" """
if not isinstance(size, (list, tuple)): if not isinstance(size, (list, tuple)):
msg = "Size must be a tuple" msg = "Size must be a list or tuple"
raise ValueError(msg) raise ValueError(msg)
if len(size) != 2: if len(size) != 2:
msg = "Size must be a tuple of length 2" msg = "Size must be a sequence of length 2"
raise ValueError(msg) raise ValueError(msg)
if size[0] < 0 or size[1] < 0: if size[0] < 0 or size[1] < 0:
msg = "Width and height must be >= 0" msg = "Width and height must be >= 0"
raise ValueError(msg) raise ValueError(msg)
return True
def new( def new(
mode: str, size: tuple[int, int], color: float | tuple[float, ...] | str | None = 0 mode: str,
size: tuple[int, int] | list[int],
color: float | tuple[float, ...] | str | None = 0,
) -> Image: ) -> Image:
""" """
Creates a new image with the given mode and size. Creates a new image with the given mode and size.
@ -3003,16 +3072,28 @@ def new(
color = ImageColor.getcolor(color, mode) color = ImageColor.getcolor(color, mode)
im = Image() im = Image()
if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: if (
# RGB or RGBA value for a P image mode == "P"
from . import ImagePalette and isinstance(color, (list, tuple))
and all(isinstance(i, int) for i in color)
):
color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color))
if len(color_ints) == 3 or len(color_ints) == 4:
# RGB or RGBA value for a P image
from . import ImagePalette
im.palette = ImagePalette.ImagePalette() im.palette = ImagePalette.ImagePalette()
color = im.palette.getcolor(color) color = im.palette.getcolor(color_ints)
return im._new(core.fill(mode, size, color)) return im._new(core.fill(mode, size, color))
def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: def frombytes(
mode: str,
size: tuple[int, int],
data: bytes | bytearray,
decoder_name: str = "raw",
*args: Any,
) -> Image:
""" """
Creates a copy of an image memory from pixel data in a buffer. Creates a copy of an image memory from pixel data in a buffer.
@ -3040,18 +3121,21 @@ def frombytes(mode, size, data, decoder_name="raw", *args) -> Image:
im = new(mode, size) im = new(mode, size)
if im.width != 0 and im.height != 0: if im.width != 0 and im.height != 0:
# may pass tuple instead of argument list decoder_args: Any = args
if len(args) == 1 and isinstance(args[0], tuple): if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple):
args = args[0] # may pass tuple instead of argument list
decoder_args = decoder_args[0]
if decoder_name == "raw" and args == (): if decoder_name == "raw" and decoder_args == ():
args = mode decoder_args = mode
im.frombytes(data, decoder_name, args) im.frombytes(data, decoder_name, decoder_args)
return im return im
def frombuffer(mode, size, data, decoder_name="raw", *args) -> Image: def frombuffer(
mode: str, size: tuple[int, int], data, decoder_name: str = "raw", *args: Any
) -> Image:
""" """
Creates an image memory referencing pixel data in a byte buffer. Creates an image memory referencing pixel data in a byte buffer.
@ -3508,7 +3592,7 @@ def merge(mode: str, bands: Sequence[Image]) -> Image:
def register_open( def register_open(
id, id: str,
factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile],
accept: Callable[[bytes], bool | str] | None = None, accept: Callable[[bytes], bool | str] | None = None,
) -> None: ) -> None:
@ -3542,7 +3626,9 @@ def register_mime(id: str, mimetype: str) -> None:
MIME[id.upper()] = mimetype MIME[id.upper()] = mimetype
def register_save(id: str, driver) -> None: def register_save(
id: str, driver: Callable[[Image, IO[bytes], str | bytes], None]
) -> None:
""" """
Registers an image save function. This function should not be Registers an image save function. This function should not be
used in application code. used in application code.
@ -3553,7 +3639,9 @@ def register_save(id: str, driver) -> None:
SAVE[id.upper()] = driver SAVE[id.upper()] = driver
def register_save_all(id, driver) -> None: def register_save_all(
id: str, driver: Callable[[Image, IO[bytes], str | bytes], None]
) -> None:
""" """
Registers an image function to save all the frames Registers an image function to save all the frames
of a multiframe format. This function should not be of a multiframe format. This function should not be
@ -3565,7 +3653,7 @@ def register_save_all(id, driver) -> None:
SAVE_ALL[id.upper()] = driver SAVE_ALL[id.upper()] = driver
def register_extension(id, extension) -> None: def register_extension(id: str, extension: str) -> None:
""" """
Registers an image extension. This function should not be Registers an image extension. This function should not be
used in application code. used in application code.
@ -3576,7 +3664,7 @@ def register_extension(id, extension) -> None:
EXTENSION[extension.lower()] = id.upper() EXTENSION[extension.lower()] = id.upper()
def register_extensions(id, extensions) -> None: def register_extensions(id: str, extensions: list[str]) -> None:
""" """
Registers image extensions. This function should not be Registers image extensions. This function should not be
used in application code. used in application code.
@ -3588,7 +3676,7 @@ def register_extensions(id, extensions) -> None:
register_extension(id, extension) register_extension(id, extension)
def registered_extensions(): def registered_extensions() -> dict[str, str]:
""" """
Returns a dictionary containing all file extensions belonging Returns a dictionary containing all file extensions belonging
to registered plugins to registered plugins
@ -3627,7 +3715,7 @@ def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None:
# Simple display support. # Simple display support.
def _show(image, **options) -> None: def _show(image: Image, **options: Any) -> None:
from . import ImageShow from . import ImageShow
ImageShow.show(image, **options) ImageShow.show(image, **options)
@ -3637,7 +3725,9 @@ def _show(image, **options) -> None:
# Effects # Effects
def effect_mandelbrot(size, extent, quality): def effect_mandelbrot(
size: tuple[int, int], extent: tuple[float, float, float, float], quality: int
) -> Image:
""" """
Generate a Mandelbrot set covering the given extent. Generate a Mandelbrot set covering the given extent.
@ -3650,7 +3740,7 @@ def effect_mandelbrot(size, extent, quality):
return Image()._new(core.effect_mandelbrot(size, extent, quality)) return Image()._new(core.effect_mandelbrot(size, extent, quality))
def effect_noise(size, sigma): def effect_noise(size: tuple[int, int], sigma: float) -> Image:
""" """
Generate Gaussian noise centered around 128. Generate Gaussian noise centered around 128.
@ -3661,7 +3751,7 @@ def effect_noise(size, sigma):
return Image()._new(core.effect_noise(size, sigma)) return Image()._new(core.effect_noise(size, sigma))
def linear_gradient(mode): def linear_gradient(mode: str) -> Image:
""" """
Generate 256x256 linear gradient from black to white, top to bottom. Generate 256x256 linear gradient from black to white, top to bottom.
@ -3670,7 +3760,7 @@ def linear_gradient(mode):
return Image()._new(core.linear_gradient(mode)) return Image()._new(core.linear_gradient(mode))
def radial_gradient(mode): def radial_gradient(mode: str) -> Image:
""" """
Generate 256x256 radial gradient from black to white, centre to edge. Generate 256x256 radial gradient from black to white, centre to edge.
@ -3683,19 +3773,18 @@ def radial_gradient(mode):
# Resources # Resources
def _apply_env_variables(env=None) -> None: def _apply_env_variables(env: dict[str, str] | None = None) -> None:
if env is None: env_dict = env if env is not None else os.environ
env = os.environ
for var_name, setter in [ for var_name, setter in [
("PILLOW_ALIGNMENT", core.set_alignment), ("PILLOW_ALIGNMENT", core.set_alignment),
("PILLOW_BLOCK_SIZE", core.set_block_size), ("PILLOW_BLOCK_SIZE", core.set_block_size),
("PILLOW_BLOCKS_MAX", core.set_blocks_max), ("PILLOW_BLOCKS_MAX", core.set_blocks_max),
]: ]:
if var_name not in env: if var_name not in env_dict:
continue continue
var = env[var_name].lower() var = env_dict[var_name].lower()
units = 1 units = 1
for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]:
@ -3704,13 +3793,13 @@ def _apply_env_variables(env=None) -> None:
var = var[: -len(postfix)] var = var[: -len(postfix)]
try: try:
var = int(var) * units var_int = int(var) * units
except ValueError: except ValueError:
warnings.warn(f"{var_name} is not int") warnings.warn(f"{var_name} is not int")
continue continue
try: try:
setter(var) setter(var_int)
except ValueError as e: except ValueError as e:
warnings.warn(f"{var_name}: {e}") warnings.warn(f"{var_name}: {e}")

View File

@ -754,7 +754,7 @@ def applyTransform(
def createProfile( def createProfile(
colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = -1 colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0
) -> core.CmsProfile: ) -> core.CmsProfile:
""" """
(pyCMS) Creates a profile. (pyCMS) Creates a profile.
@ -777,7 +777,7 @@ def createProfile(
:param colorSpace: String, the color space of the profile you wish to :param colorSpace: String, the color space of the profile you wish to
create. create.
Currently only "LAB", "XYZ", and "sRGB" are supported. Currently only "LAB", "XYZ", and "sRGB" are supported.
:param colorTemp: Positive integer for the white point for the profile, in :param colorTemp: Positive number for the white point for the profile, in
degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50
illuminant if omitted (5000k). colorTemp is ONLY applied to LAB illuminant if omitted (5000k). colorTemp is ONLY applied to LAB
profiles, and is ignored for XYZ and sRGB. profiles, and is ignored for XYZ and sRGB.
@ -1089,7 +1089,7 @@ def isIntentSupported(
raise PyCMSError(v) from v raise PyCMSError(v) from v
def versions() -> tuple[str, str, str, str]: def versions() -> tuple[str, str | None, str, str]:
""" """
(pyCMS) Fetches versions. (pyCMS) Fetches versions.
""" """

View File

@ -25,7 +25,7 @@ from . import Image
@lru_cache @lru_cache
def getrgb(color): def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]:
""" """
Convert a color string to an RGB or RGBA tuple. If the string cannot be Convert a color string to an RGB or RGBA tuple. If the string cannot be
parsed, this function raises a :py:exc:`ValueError` exception. parsed, this function raises a :py:exc:`ValueError` exception.
@ -44,8 +44,10 @@ def getrgb(color):
if rgb: if rgb:
if isinstance(rgb, tuple): if isinstance(rgb, tuple):
return rgb return rgb
colormap[color] = rgb = getrgb(rgb) rgb_tuple = getrgb(rgb)
return rgb assert len(rgb_tuple) == 3
colormap[color] = rgb_tuple
return rgb_tuple
# check for known string formats # check for known string formats
if re.match("#[a-f0-9]{3}$", color): if re.match("#[a-f0-9]{3}$", color):
@ -88,15 +90,15 @@ def getrgb(color):
if m: if m:
from colorsys import hls_to_rgb from colorsys import hls_to_rgb
rgb = hls_to_rgb( rgb_floats = hls_to_rgb(
float(m.group(1)) / 360.0, float(m.group(1)) / 360.0,
float(m.group(3)) / 100.0, float(m.group(3)) / 100.0,
float(m.group(2)) / 100.0, float(m.group(2)) / 100.0,
) )
return ( return (
int(rgb[0] * 255 + 0.5), int(rgb_floats[0] * 255 + 0.5),
int(rgb[1] * 255 + 0.5), int(rgb_floats[1] * 255 + 0.5),
int(rgb[2] * 255 + 0.5), int(rgb_floats[2] * 255 + 0.5),
) )
m = re.match( m = re.match(
@ -105,15 +107,15 @@ def getrgb(color):
if m: if m:
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
rgb = hsv_to_rgb( rgb_floats = hsv_to_rgb(
float(m.group(1)) / 360.0, float(m.group(1)) / 360.0,
float(m.group(2)) / 100.0, float(m.group(2)) / 100.0,
float(m.group(3)) / 100.0, float(m.group(3)) / 100.0,
) )
return ( return (
int(rgb[0] * 255 + 0.5), int(rgb_floats[0] * 255 + 0.5),
int(rgb[1] * 255 + 0.5), int(rgb_floats[1] * 255 + 0.5),
int(rgb[2] * 255 + 0.5), int(rgb_floats[2] * 255 + 0.5),
) )
m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
@ -124,7 +126,7 @@ def getrgb(color):
@lru_cache @lru_cache
def getcolor(color, mode: str) -> tuple[int, ...]: def getcolor(color: str, mode: str) -> int | tuple[int, ...]:
""" """
Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if
``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is
@ -136,33 +138,34 @@ def getcolor(color, mode: str) -> tuple[int, ...]:
:param color: A color string :param color: A color string
:param mode: Convert result to this mode :param mode: Convert result to this mode
:return: ``(graylevel[, alpha]) or (red, green, blue[, alpha])`` :return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])``
""" """
# same as getrgb, but converts the result to the given mode # same as getrgb, but converts the result to the given mode
color, alpha = getrgb(color), 255 rgb, alpha = getrgb(color), 255
if len(color) == 4: if len(rgb) == 4:
color, alpha = color[:3], color[3] alpha = rgb[3]
rgb = rgb[:3]
if mode == "HSV": if mode == "HSV":
from colorsys import rgb_to_hsv from colorsys import rgb_to_hsv
r, g, b = color r, g, b = rgb
h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255)
return int(h * 255), int(s * 255), int(v * 255) return int(h * 255), int(s * 255), int(v * 255)
elif Image.getmodebase(mode) == "L": elif Image.getmodebase(mode) == "L":
r, g, b = color r, g, b = rgb
# ITU-R Recommendation 601-2 for nonlinear RGB # ITU-R Recommendation 601-2 for nonlinear RGB
# scaled to 24 bits to match the convert's implementation. # scaled to 24 bits to match the convert's implementation.
color = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16
if mode[-1] == "A": if mode[-1] == "A":
return color, alpha return graylevel, alpha
else: return graylevel
if mode[-1] == "A": elif mode[-1] == "A":
return color + (alpha,) return rgb + (alpha,)
return color return rgb
colormap = { colormap: dict[str, str | tuple[int, int, int]] = {
# X11 colour table from https://drafts.csswg.org/css-color-4/, with # X11 colour table from https://drafts.csswg.org/css-color-4/, with
# gray/grey spelling issues fixed. This is a superset of HTML 4.0 # gray/grey spelling issues fixed. This is a superset of HTML 4.0
# colour names used in CSS 1. # colour names used in CSS 1.

View File

@ -34,11 +34,25 @@ from __future__ import annotations
import math import math
import numbers import numbers
import struct import struct
from typing import TYPE_CHECKING, Sequence, cast from types import ModuleType
from typing import TYPE_CHECKING, AnyStr, Callable, List, Sequence, Tuple, Union, cast
from . import Image, ImageColor from . import Image, ImageColor
from ._deprecate import deprecate
from ._typing import Coords from ._typing import Coords
# experimental access to the outline API
Outline: Callable[[], Image.core._Outline] | None
try:
Outline = Image.core.outline
except AttributeError:
Outline = None
if TYPE_CHECKING:
from . import ImageDraw2, ImageFont
_Ink = Union[float, Tuple[int, ...], str]
""" """
A simple 2D drawing interface for PIL images. A simple 2D drawing interface for PIL images.
<p> <p>
@ -48,7 +62,9 @@ directly.
class ImageDraw: class ImageDraw:
font = None font: (
ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None
) = None
def __init__(self, im: Image.Image, mode: str | None = None) -> None: def __init__(self, im: Image.Image, mode: str | None = None) -> None:
""" """
@ -92,10 +108,9 @@ class ImageDraw:
self.fontmode = "L" # aliasing is okay for other modes self.fontmode = "L" # aliasing is okay for other modes
self.fill = False self.fill = False
if TYPE_CHECKING: def getfont(
from . import ImageFont self,
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
def getfont(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
""" """
Get the current default font. Get the current default font.
@ -120,43 +135,57 @@ class ImageDraw:
self.font = ImageFont.load_default() self.font = ImageFont.load_default()
return self.font return self.font
def _getfont(self, font_size: float | None): def _getfont(
self, font_size: float | None
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
if font_size is not None: if font_size is not None:
from . import ImageFont from . import ImageFont
font = ImageFont.load_default(font_size) return ImageFont.load_default(font_size)
else: else:
font = self.getfont() return self.getfont()
return font
def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: def _getink(
self, ink: _Ink | None, fill: _Ink | None = None
) -> tuple[int | None, int | None]:
result_ink = None
result_fill = None
if ink is None and fill is None: if ink is None and fill is None:
if self.fill: if self.fill:
fill = self.ink result_fill = self.ink
else: else:
ink = self.ink result_ink = self.ink
else: else:
if ink is not None: if ink is not None:
if isinstance(ink, str): if isinstance(ink, str):
ink = ImageColor.getcolor(ink, self.mode) ink = ImageColor.getcolor(ink, self.mode)
if self.palette and not isinstance(ink, numbers.Number): if self.palette and not isinstance(ink, numbers.Number):
ink = self.palette.getcolor(ink, self._image) ink = self.palette.getcolor(ink, self._image)
ink = self.draw.draw_ink(ink) result_ink = self.draw.draw_ink(ink)
if fill is not None: if fill is not None:
if isinstance(fill, str): if isinstance(fill, str):
fill = ImageColor.getcolor(fill, self.mode) fill = ImageColor.getcolor(fill, self.mode)
if self.palette and not isinstance(fill, numbers.Number): if self.palette and not isinstance(fill, numbers.Number):
fill = self.palette.getcolor(fill, self._image) fill = self.palette.getcolor(fill, self._image)
fill = self.draw.draw_ink(fill) result_fill = self.draw.draw_ink(fill)
return ink, fill return result_ink, result_fill
def arc(self, xy: Coords, start, end, fill=None, width=1) -> None: def arc(
self,
xy: Coords,
start: float,
end: float,
fill: _Ink | None = None,
width: int = 1,
) -> None:
"""Draw an arc.""" """Draw an arc."""
ink, fill = self._getink(fill) ink, fill = self._getink(fill)
if ink is not None: if ink is not None:
self.draw.draw_arc(xy, start, end, ink, width) self.draw.draw_arc(xy, start, end, ink, width)
def bitmap(self, xy: Sequence[int], bitmap, fill=None) -> None: def bitmap(
self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None
) -> None:
"""Draw a bitmap.""" """Draw a bitmap."""
bitmap.load() bitmap.load()
ink, fill = self._getink(fill) ink, fill = self._getink(fill)
@ -165,23 +194,55 @@ class ImageDraw:
if ink is not None: if ink is not None:
self.draw.draw_bitmap(xy, bitmap.im, ink) self.draw.draw_bitmap(xy, bitmap.im, ink)
def chord(self, xy: Coords, start, end, fill=None, outline=None, width=1) -> None: def chord(
self,
xy: Coords,
start: float,
end: float,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None:
"""Draw a chord.""" """Draw a chord."""
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
if fill is not None: if fill_ink is not None:
self.draw.draw_chord(xy, start, end, fill, 1) self.draw.draw_chord(xy, start, end, fill_ink, 1)
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill_ink and width != 0:
self.draw.draw_chord(xy, start, end, ink, 0, width) self.draw.draw_chord(xy, start, end, ink, 0, width)
def ellipse(self, xy: Coords, fill=None, outline=None, width=1) -> None: def ellipse(
self,
xy: Coords,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None:
"""Draw an ellipse.""" """Draw an ellipse."""
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
if fill is not None: if fill_ink is not None:
self.draw.draw_ellipse(xy, fill, 1) self.draw.draw_ellipse(xy, fill_ink, 1)
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill_ink and width != 0:
self.draw.draw_ellipse(xy, ink, 0, width) self.draw.draw_ellipse(xy, ink, 0, width)
def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: def circle(
self,
xy: Sequence[float],
radius: float,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None:
"""Draw a circle given center coordinates and a radius."""
ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
self.ellipse(ellipse_xy, fill, outline, width)
def line(
self,
xy: Coords,
fill: _Ink | None = None,
width: int = 0,
joint: str | None = None,
) -> None:
"""Draw a line, or a connected sequence of line segments.""" """Draw a line, or a connected sequence of line segments."""
ink = self._getink(fill)[0] ink = self._getink(fill)[0]
if ink is not None: if ink is not None:
@ -209,7 +270,9 @@ class ImageDraw:
# This is a straight line, so no joint is required # This is a straight line, so no joint is required
continue continue
def coord_at_angle(coord, angle): def coord_at_angle(
coord: Sequence[float], angle: float
) -> tuple[float, ...]:
x, y = coord x, y = coord
angle -= 90 angle -= 90
distance = width / 2 - 1 distance = width / 2 - 1
@ -250,37 +313,54 @@ class ImageDraw:
] ]
self.line(gap_coords, fill, width=3) self.line(gap_coords, fill, width=3)
def shape(self, shape, fill=None, outline=None) -> None: def shape(
self,
shape: Image.core._Outline,
fill: _Ink | None = None,
outline: _Ink | None = None,
) -> None:
"""(Experimental) Draw a shape.""" """(Experimental) Draw a shape."""
shape.close() shape.close()
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
if fill is not None: if fill_ink is not None:
self.draw.draw_outline(shape, fill, 1) self.draw.draw_outline(shape, fill_ink, 1)
if ink is not None and ink != fill: if ink is not None and ink != fill_ink:
self.draw.draw_outline(shape, ink, 0) self.draw.draw_outline(shape, ink, 0)
def pieslice( def pieslice(
self, xy: Coords, start, end, fill=None, outline=None, width=1 self,
xy: Coords,
start: float,
end: float,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None: ) -> None:
"""Draw a pieslice.""" """Draw a pieslice."""
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
if fill is not None: if fill_ink is not None:
self.draw.draw_pieslice(xy, start, end, fill, 1) self.draw.draw_pieslice(xy, start, end, fill_ink, 1)
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill_ink and width != 0:
self.draw.draw_pieslice(xy, start, end, ink, 0, width) self.draw.draw_pieslice(xy, start, end, ink, 0, width)
def point(self, xy: Coords, fill=None) -> None: def point(self, xy: Coords, fill: _Ink | None = None) -> None:
"""Draw one or more individual pixels.""" """Draw one or more individual pixels."""
ink, fill = self._getink(fill) ink, fill = self._getink(fill)
if ink is not None: if ink is not None:
self.draw.draw_points(xy, ink) self.draw.draw_points(xy, ink)
def polygon(self, xy: Coords, fill=None, outline=None, width=1) -> None: def polygon(
self,
xy: Coords,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None:
"""Draw a polygon.""" """Draw a polygon."""
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
if fill is not None: if fill_ink is not None:
self.draw.draw_polygon(xy, fill, 1) self.draw.draw_polygon(xy, fill_ink, 1)
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill_ink and width != 0:
if width == 1: if width == 1:
self.draw.draw_polygon(xy, ink, 0, width) self.draw.draw_polygon(xy, ink, 0, width)
elif self.im is not None: elif self.im is not None:
@ -306,22 +386,41 @@ class ImageDraw:
self.im.paste(im.im, (0, 0) + im.size, mask.im) self.im.paste(im.im, (0, 0) + im.size, mask.im)
def regular_polygon( def regular_polygon(
self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 self,
bounding_circle: Sequence[Sequence[float] | float],
n_sides: int,
rotation: float = 0,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None: ) -> None:
"""Draw a regular polygon.""" """Draw a regular polygon."""
xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation)
self.polygon(xy, fill, outline, width) self.polygon(xy, fill, outline, width)
def rectangle(self, xy: Coords, fill=None, outline=None, width=1) -> None: def rectangle(
self,
xy: Coords,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None:
"""Draw a rectangle.""" """Draw a rectangle."""
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
if fill is not None: if fill_ink is not None:
self.draw.draw_rectangle(xy, fill, 1) self.draw.draw_rectangle(xy, fill_ink, 1)
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill_ink and width != 0:
self.draw.draw_rectangle(xy, ink, 0, width) self.draw.draw_rectangle(xy, ink, 0, width)
def rounded_rectangle( def rounded_rectangle(
self, xy: Coords, radius=0, fill=None, outline=None, width=1, *, corners=None self,
xy: Coords,
radius: float = 0,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
*,
corners: tuple[bool, bool, bool, bool] | None = None,
) -> None: ) -> None:
"""Draw a rounded rectangle.""" """Draw a rounded rectangle."""
if isinstance(xy[0], (list, tuple)): if isinstance(xy[0], (list, tuple)):
@ -363,10 +462,10 @@ class ImageDraw:
# that is a rectangle # that is a rectangle
return self.rectangle(xy, fill, outline, width) return self.rectangle(xy, fill, outline, width)
r = d // 2 r = int(d // 2)
ink, fill = self._getink(outline, fill) ink, fill_ink = self._getink(outline, fill)
def draw_corners(pieslice) -> None: def draw_corners(pieslice: bool) -> None:
parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] parts: tuple[tuple[tuple[float, float, float, float], int, int], ...]
if full_x: if full_x:
# Draw top and bottom halves # Draw top and bottom halves
@ -396,32 +495,32 @@ class ImageDraw:
) )
for part in parts: for part in parts:
if pieslice: if pieslice:
self.draw.draw_pieslice(*(part + (fill, 1))) self.draw.draw_pieslice(*(part + (fill_ink, 1)))
else: else:
self.draw.draw_arc(*(part + (ink, width))) self.draw.draw_arc(*(part + (ink, width)))
if fill is not None: if fill_ink is not None:
draw_corners(True) draw_corners(True)
if full_x: if full_x:
self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill, 1) self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1)
else: else:
self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill, 1) self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1)
if not full_x and not full_y: if not full_x and not full_y:
left = [x0, y0, x0 + r, y1] left = [x0, y0, x0 + r, y1]
if corners[0]: if corners[0]:
left[1] += r + 1 left[1] += r + 1
if corners[3]: if corners[3]:
left[3] -= r + 1 left[3] -= r + 1
self.draw.draw_rectangle(left, fill, 1) self.draw.draw_rectangle(left, fill_ink, 1)
right = [x1 - r, y0, x1, y1] right = [x1 - r, y0, x1, y1]
if corners[1]: if corners[1]:
right[1] += r + 1 right[1] += r + 1
if corners[2]: if corners[2]:
right[3] -= r + 1 right[3] -= r + 1
self.draw.draw_rectangle(right, fill, 1) self.draw.draw_rectangle(right, fill_ink, 1)
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill_ink and width != 0:
draw_corners(False) draw_corners(False)
if not full_x: if not full_x:
@ -453,15 +552,13 @@ class ImageDraw:
right[3] -= r + 1 right[3] -= r + 1
self.draw.draw_rectangle(right, ink, 1) self.draw.draw_rectangle(right, ink, 1)
def _multiline_check(self, text) -> bool: def _multiline_check(self, text: AnyStr) -> bool:
split_character = "\n" if isinstance(text, str) else b"\n" split_character = "\n" if isinstance(text, str) else b"\n"
return split_character in text return split_character in text
def _multiline_split(self, text) -> list[str | bytes]: def _multiline_split(self, text: AnyStr) -> list[AnyStr]:
split_character = "\n" if isinstance(text, str) else b"\n" return text.split("\n" if isinstance(text, str) else b"\n")
return text.split(split_character)
def _multiline_spacing(self, font, spacing, stroke_width): def _multiline_spacing(self, font, spacing, stroke_width):
return ( return (
@ -472,10 +569,15 @@ class ImageDraw:
def text( def text(
self, self,
xy, xy: tuple[float, float],
text, text: str,
fill=None, fill=None,
font=None, font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor=None, anchor=None,
spacing=4, spacing=4,
align="left", align="left",
@ -513,10 +615,11 @@ class ImageDraw:
embedded_color, embedded_color,
) )
def getink(fill): def getink(fill: _Ink | None) -> int:
ink, fill = self._getink(fill) ink, fill_ink = self._getink(fill)
if ink is None: if ink is None:
return fill assert fill_ink is not None
return fill_ink
return ink return ink
def draw_text(ink, stroke_width=0, stroke_offset=None) -> None: def draw_text(ink, stroke_width=0, stroke_offset=None) -> None:
@ -529,7 +632,7 @@ class ImageDraw:
coord.append(int(xy[i])) coord.append(int(xy[i]))
start.append(math.modf(xy[i])[0]) start.append(math.modf(xy[i])[0])
try: try:
mask, offset = font.getmask2( mask, offset = font.getmask2( # type: ignore[union-attr,misc]
text, text,
mode, mode,
direction=direction, direction=direction,
@ -545,7 +648,7 @@ class ImageDraw:
coord = [coord[0] + offset[0], coord[1] + offset[1]] coord = [coord[0] + offset[0], coord[1] + offset[1]]
except AttributeError: except AttributeError:
try: try:
mask = font.getmask( mask = font.getmask( # type: ignore[misc]
text, text,
mode, mode,
direction, direction,
@ -594,10 +697,15 @@ class ImageDraw:
def multiline_text( def multiline_text(
self, self,
xy, xy: tuple[float, float],
text, text: str,
fill=None, fill=None,
font=None, font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor=None, anchor=None,
spacing=4, spacing=4,
align="left", align="left",
@ -627,7 +735,7 @@ class ImageDraw:
font = self._getfont(font_size) font = self._getfont(font_size)
widths = [] widths = []
max_width = 0 max_width: float = 0
lines = self._multiline_split(text) lines = self._multiline_split(text)
line_spacing = self._multiline_spacing(font, spacing, stroke_width) line_spacing = self._multiline_spacing(font, spacing, stroke_width)
for line in lines: for line in lines:
@ -681,15 +789,20 @@ class ImageDraw:
def textlength( def textlength(
self, self,
text, text: str,
font=None, font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
direction=None, direction=None,
features=None, features=None,
language=None, language=None,
embedded_color=False, embedded_color=False,
*, *,
font_size=None, font_size=None,
): ) -> float:
"""Get the length of a given string, in pixels with 1/64 precision.""" """Get the length of a given string, in pixels with 1/64 precision."""
if self._multiline_check(text): if self._multiline_check(text):
msg = "can't measure length of multiline text" msg = "can't measure length of multiline text"
@ -781,7 +894,7 @@ class ImageDraw:
font = self._getfont(font_size) font = self._getfont(font_size)
widths = [] widths = []
max_width = 0 max_width: float = 0
lines = self._multiline_split(text) lines = self._multiline_split(text)
line_spacing = self._multiline_spacing(font, spacing, stroke_width) line_spacing = self._multiline_spacing(font, spacing, stroke_width)
for line in lines: for line in lines:
@ -853,7 +966,7 @@ class ImageDraw:
return bbox return bbox
def Draw(im, mode: str | None = None) -> ImageDraw: def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw:
""" """
A simple 2D drawing interface for PIL images. A simple 2D drawing interface for PIL images.
@ -865,43 +978,34 @@ def Draw(im, mode: str | None = None) -> ImageDraw:
defaults to the mode of the image. defaults to the mode of the image.
""" """
try: try:
return im.getdraw(mode) return getattr(im, "getdraw")(mode)
except AttributeError: except AttributeError:
return ImageDraw(im, mode) return ImageDraw(im, mode)
# experimental access to the outline API def getdraw(
try: im: Image.Image | None = None, hints: list[str] | None = None
Outline = Image.core.outline ) -> tuple[ImageDraw2.Draw | None, ModuleType]:
except AttributeError:
Outline = None
def getdraw(im=None, hints=None):
""" """
(Experimental) A more advanced 2D drawing interface for PIL images,
based on the WCK interface.
:param im: The image to draw in. :param im: The image to draw in.
:param hints: An optional list of hints. :param hints: An optional list of hints. Deprecated.
:returns: A (drawing context, drawing resource factory) tuple. :returns: A (drawing context, drawing resource factory) tuple.
""" """
# FIXME: this needs more work! if hints is not None:
# FIXME: come up with a better 'hints' scheme. deprecate("'hints' parameter", 12)
handler = None from . import ImageDraw2
if not hints or "nicest" in hints:
try: draw = ImageDraw2.Draw(im) if im is not None else None
from . import _imagingagg as handler return draw, ImageDraw2
except ImportError:
pass
if handler is None:
from . import ImageDraw2 as handler
if im:
im = handler.Draw(im)
return im, handler
def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: def floodfill(
image: Image.Image,
xy: tuple[int, int],
value: float | tuple[int, ...],
border: float | tuple[int, ...] | None = None,
thresh: float = 0,
) -> None:
""" """
(experimental) Fills a bounded region with a given color. (experimental) Fills a bounded region with a given color.
@ -958,12 +1062,12 @@ def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None:
def _compute_regular_polygon_vertices( def _compute_regular_polygon_vertices(
bounding_circle, n_sides, rotation bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float
) -> list[tuple[float, float]]: ) -> list[tuple[float, float]]:
""" """
Generate a list of vertices for a 2D regular polygon. Generate a list of vertices for a 2D regular polygon.
:param bounding_circle: The bounding circle is a tuple defined :param bounding_circle: The bounding circle is a sequence defined
by a point and radius. The polygon is inscribed in this circle. by a point and radius. The polygon is inscribed in this circle.
(e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``)
:param n_sides: Number of sides :param n_sides: Number of sides
@ -1001,7 +1105,7 @@ def _compute_regular_polygon_vertices(
# 1. Error Handling # 1. Error Handling
# 1.1 Check `n_sides` has an appropriate value # 1.1 Check `n_sides` has an appropriate value
if not isinstance(n_sides, int): if not isinstance(n_sides, int):
msg = "n_sides should be an int" msg = "n_sides should be an int" # type: ignore[unreachable]
raise TypeError(msg) raise TypeError(msg)
if n_sides < 3: if n_sides < 3:
msg = "n_sides should be an int > 2" msg = "n_sides should be an int > 2"
@ -1013,9 +1117,24 @@ def _compute_regular_polygon_vertices(
raise TypeError(msg) raise TypeError(msg)
if len(bounding_circle) == 3: if len(bounding_circle) == 3:
*centroid, polygon_radius = bounding_circle if not all(isinstance(i, (int, float)) for i in bounding_circle):
elif len(bounding_circle) == 2: msg = "bounding_circle should only contain numeric data"
centroid, polygon_radius = bounding_circle raise ValueError(msg)
*centroid, polygon_radius = cast(List[float], list(bounding_circle))
elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)):
if not all(
isinstance(i, (int, float)) for i in bounding_circle[0]
) or not isinstance(bounding_circle[1], (int, float)):
msg = "bounding_circle should only contain numeric data"
raise ValueError(msg)
if len(bounding_circle[0]) != 2:
msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))"
raise ValueError(msg)
centroid = cast(List[float], list(bounding_circle[0]))
polygon_radius = cast(float, bounding_circle[1])
else: else:
msg = ( msg = (
"bounding_circle should contain 2D coordinates " "bounding_circle should contain 2D coordinates "
@ -1023,25 +1142,17 @@ def _compute_regular_polygon_vertices(
) )
raise ValueError(msg) raise ValueError(msg)
if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)):
msg = "bounding_circle should only contain numeric data"
raise ValueError(msg)
if not len(centroid) == 2:
msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))"
raise ValueError(msg)
if polygon_radius <= 0: if polygon_radius <= 0:
msg = "bounding_circle radius should be > 0" msg = "bounding_circle radius should be > 0"
raise ValueError(msg) raise ValueError(msg)
# 1.3 Check `rotation` has an appropriate value # 1.3 Check `rotation` has an appropriate value
if not isinstance(rotation, (int, float)): if not isinstance(rotation, (int, float)):
msg = "rotation should be an int or float" msg = "rotation should be an int or float" # type: ignore[unreachable]
raise ValueError(msg) raise ValueError(msg)
# 2. Define Helper Functions # 2. Define Helper Functions
def _apply_rotation(point: list[float], degrees: float) -> tuple[int, int]: def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]:
return ( return (
round( round(
point[0] * math.cos(math.radians(360 - degrees)) point[0] * math.cos(math.radians(360 - degrees))
@ -1057,7 +1168,7 @@ def _compute_regular_polygon_vertices(
), ),
) )
def _compute_polygon_vertex(angle: float) -> tuple[int, int]: def _compute_polygon_vertex(angle: float) -> tuple[float, float]:
start_point = [polygon_radius, 0] start_point = [polygon_radius, 0]
return _apply_rotation(start_point, angle) return _apply_rotation(start_point, angle)
@ -1080,11 +1191,13 @@ def _compute_regular_polygon_vertices(
return [_compute_polygon_vertex(angle) for angle in angles] return [_compute_polygon_vertex(angle) for angle in angles]
def _color_diff(color1, color2: float | tuple[int, ...]) -> float: def _color_diff(
color1: float | tuple[int, ...], color2: float | tuple[int, ...]
) -> float:
""" """
Uses 1-norm distance to calculate difference between two values. Uses 1-norm distance to calculate difference between two values.
""" """
if isinstance(color2, tuple): first = color1 if isinstance(color1, tuple) else (color1,)
return sum(abs(color1[i] - color2[i]) for i in range(0, len(color2))) second = color2 if isinstance(color2, tuple) else (color2,)
else:
return abs(color1 - color2) return sum(abs(first[i] - second[i]) for i in range(0, len(second)))

View File

@ -30,7 +30,7 @@ from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
class Pen: class Pen:
"""Stores an outline color and width.""" """Stores an outline color and width."""
def __init__(self, color, width=1, opacity=255): def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None:
self.color = ImageColor.getrgb(color) self.color = ImageColor.getrgb(color)
self.width = width self.width = width
@ -38,7 +38,7 @@ class Pen:
class Brush: class Brush:
"""Stores a fill color""" """Stores a fill color"""
def __init__(self, color, opacity=255): def __init__(self, color: str, opacity: int = 255) -> None:
self.color = ImageColor.getrgb(color) self.color = ImageColor.getrgb(color)
@ -63,7 +63,7 @@ class Draw:
self.image = image self.image = image
self.transform = None self.transform = None
def flush(self): def flush(self) -> Image.Image:
return self.image return self.image
def render(self, op, xy, pen, brush=None): def render(self, op, xy, pen, brush=None):

View File

@ -23,7 +23,10 @@ from . import Image, ImageFilter, ImageStat
class _Enhance: class _Enhance:
def enhance(self, factor): image: Image.Image
degenerate: Image.Image
def enhance(self, factor: float) -> Image.Image:
""" """
Returns an enhanced image. Returns an enhanced image.
@ -46,7 +49,7 @@ class Color(_Enhance):
the original image. the original image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
self.intermediate_mode = "L" self.intermediate_mode = "L"
if "A" in image.getbands(): if "A" in image.getbands():
@ -63,7 +66,7 @@ class Contrast(_Enhance):
gives a solid gray image. A factor of 1.0 gives the original image. gives a solid gray image. A factor of 1.0 gives the original image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5) mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5)
self.degenerate = Image.new("L", image.size, mean).convert(image.mode) self.degenerate = Image.new("L", image.size, mean).convert(image.mode)
@ -80,7 +83,7 @@ class Brightness(_Enhance):
original image. original image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
self.degenerate = Image.new(image.mode, image.size, 0) self.degenerate = Image.new(image.mode, image.size, 0)
@ -96,7 +99,7 @@ class Sharpness(_Enhance):
original image, and a factor of 2.0 gives a sharpened image. original image, and a factor of 2.0 gives a sharpened image.
""" """
def __init__(self, image): def __init__(self, image: Image.Image) -> None:
self.image = image self.image = image
self.degenerate = image.filter(ImageFilter.SMOOTH) self.degenerate = image.filter(ImageFilter.SMOOTH)

View File

@ -28,6 +28,7 @@
# #
from __future__ import annotations from __future__ import annotations
import abc
import io import io
import itertools import itertools
import struct import struct
@ -347,6 +348,15 @@ class ImageFile(Image.Image):
return self.tell() != frame return self.tell() != frame
class StubHandler:
def open(self, im: StubImageFile) -> None:
pass
@abc.abstractmethod
def load(self, im: StubImageFile) -> Image.Image:
pass
class StubImageFile(ImageFile): class StubImageFile(ImageFile):
""" """
Base class for stub image loaders. Base class for stub image loaders.
@ -477,7 +487,7 @@ class Parser:
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, *args): def __exit__(self, *args: object) -> None:
self.close() self.close()
def close(self): def close(self):
@ -753,7 +763,7 @@ class PyEncoder(PyCodec):
def pushes_fd(self): def pushes_fd(self):
return self._pushes_fd return self._pushes_fd
def encode(self, bufsize): def encode(self, bufsize: int) -> tuple[int, int, bytes]:
""" """
Override to perform the encoding process. Override to perform the encoding process.

Some files were not shown because too many files have changed in this diff Show More