Merge branch 'main' into progress

This commit is contained in:
Andrew Murray 2024-06-26 18:45:50 +10:00 committed by GitHub
commit 2f27173d8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 1252 additions and 741 deletions

View File

@ -35,7 +35,7 @@ install:
- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-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.1 - choco install ghostscript --version=10.3.1
- path c:\nasm-2.16.03;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.19.0 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

@ -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,51 @@ Changelog (Pillow)
10.4.0 (unreleased) 10.4.0 (unreleased)
------------------- -------------------
- Do not use first frame duration for other frames when saving APNG images #8104
[radarhere]
- Consider I;16 pixel size when using a 1 mode mask #8112
[radarhere]
- When saving multiple PNG frames, convert to mode rather than raw mode #8087
[radarhere]
- Added byte support to FreeTypeFont #8141
[radarhere]
- Allow float center for rotate operations #8114
[radarhere]
- Do not read layers immediately when opening PSD images #8039
[radarhere]
- Restore original thread state #8065
[radarhere]
- Read IM and TIFF images as RGB, rather than RGBX #7997
[radarhere]
- Only preserve TIFF IPTC_NAA_CHUNK tag if type is BYTE or UNDEFINED #7948
[radarhere]
- Clarify ImageDraw2 error message when size is missing #8165
[radarhere]
- 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 - Accept 't' suffix for libtiff version #8126, #8129
[radarhere] [radarhere]

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__)
@ -240,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))

Binary file not shown.

BIN
Tests/images/ultrahdr.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

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

@ -759,10 +759,21 @@ def test_different_modes_in_later_frames(
assert reloaded.mode == mode assert reloaded.mode == mode
def test_apng_repeated_seeks_give_correct_info() -> None: def test_different_durations(tmp_path: Path) -> None:
test_file = str(tmp_path / "temp.png")
with Image.open("Tests/images/apng/different_durations.png") as im: with Image.open("Tests/images/apng/different_durations.png") as im:
for i in range(3): for _ in range(3):
im.seek(0) im.seek(0)
assert im.info["duration"] == 4000 assert im.info["duration"] == 4000
im.seek(1) im.seek(1)
assert im.info["duration"] == 1000 assert im.info["duration"] == 1000
im.save(test_file, save_all=True)
with Image.open(test_file) as reloaded:
assert reloaded.info["duration"] == 4000
reloaded.seek(1)
assert reloaded.info["duration"] == 1000

View File

@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None:
im.fp.close() im.fp.close()
return Image.new("RGB", (1, 1)) return Image.new("RGB", (1, 1))
def is_loaded(self) -> bool:
return self.loaded
def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None:
self.saved = True self.saved = True
@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None:
BufrStubImagePlugin.register_handler(handler) BufrStubImagePlugin.register_handler(handler)
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
assert handler.opened assert handler.opened
assert not handler.loaded assert not handler.is_loaded()
im.load() im.load()
assert handler.loaded assert handler.is_loaded()
temp_file = str(tmp_path / "temp.bufr") temp_file = str(tmp_path / "temp.bufr")
im.save(temp_file) im.save(temp_file)

View File

@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None:
im.fp.close() im.fp.close()
return Image.new("RGB", (1, 1)) return Image.new("RGB", (1, 1))
def is_loaded(self) -> bool:
return self.loaded
def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None:
self.saved = True self.saved = True
@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None:
GribStubImagePlugin.register_handler(handler) GribStubImagePlugin.register_handler(handler)
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
assert handler.opened assert handler.opened
assert not handler.loaded assert not handler.is_loaded()
im.load() im.load()
assert handler.loaded assert handler.is_loaded()
temp_file = str(tmp_path / "temp.grib") temp_file = str(tmp_path / "temp.grib")
im.save(temp_file) im.save(temp_file)

View File

@ -66,6 +66,9 @@ def test_handler(tmp_path: Path) -> None:
im.fp.close() im.fp.close()
return Image.new("RGB", (1, 1)) return Image.new("RGB", (1, 1))
def is_loaded(self) -> bool:
return self.loaded
def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None:
self.saved = True self.saved = True
@ -73,10 +76,10 @@ def test_handler(tmp_path: Path) -> None:
Hdf5StubImagePlugin.register_handler(handler) Hdf5StubImagePlugin.register_handler(handler)
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
assert handler.opened assert handler.opened
assert not handler.loaded assert not handler.is_loaded()
im.load() im.load()
assert handler.loaded assert handler.is_loaded()
temp_file = str(tmp_path / "temp.h5") temp_file = str(tmp_path / "temp.h5")
im.save(temp_file) im.save(temp_file)

View File

@ -443,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: JpegImagePlugin.JpegImageFile): 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]
@ -699,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)
@ -917,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:
@ -945,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"]
@ -1029,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

@ -335,9 +335,15 @@ def test_issue_6194() -> None:
assert im.getpixel((5, 5)) == 31 assert im.getpixel((5, 5)) == 31
def test_unknown_j2k_mode() -> None:
with pytest.raises(UnidentifiedImageError):
with Image.open("Tests/images/unknown_mode.j2k"):
pass
def test_unbound_local() -> None: def test_unbound_local() -> None:
# prepatch, a malformed jp2 file could cause an UnboundLocalError exception. # prepatch, a malformed jp2 file could cause an UnboundLocalError exception.
with pytest.raises(OSError): with pytest.raises(UnidentifiedImageError):
with Image.open("Tests/images/unbound_variable.jp2"): with Image.open("Tests/images/unbound_variable.jp2"):
pass pass
@ -460,7 +466,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

@ -226,6 +226,11 @@ def test_eoferror() -> None:
im.seek(n_frames - 1) im.seek(n_frames - 1)
def test_ultra_hdr() -> None:
with Image.open("Tests/images/ultrahdr.jpg") as im:
assert im.format == "JPEG"
@pytest.mark.parametrize("test_file", test_files) @pytest.mark.parametrize("test_file", test_files)
def test_image_grab(test_file: str) -> None: def test_image_grab(test_file: str) -> None:
with Image.open(test_file) as im: with Image.open(test_file) as im:

View File

@ -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)
@ -655,11 +657,12 @@ class TestFilePng:
png.call(cid, 0, 0) png.call(cid, 0, 0)
ImageFile.LOAD_TRUNCATED_IMAGES = False ImageFile.LOAD_TRUNCATED_IMAGES = False
def test_specify_bits(self, tmp_path: Path) -> None: @pytest.mark.parametrize("save_all", (True, False))
def test_specify_bits(self, save_all: bool, tmp_path: Path) -> None:
im = hopper("P") im = hopper("P")
out = str(tmp_path / "temp.png") out = str(tmp_path / "temp.png")
im.save(out, bits=4) im.save(out, bits=4, save_all=save_all)
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert len(reloaded.png.im_palette[1]) == 48 assert len(reloaded.png.im_palette[1]) == 48
@ -683,6 +686,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,16 +771,12 @@ 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() sys.stdout = mystdout # type: ignore[assignment]
else:
mystdout = BytesIO()
sys.stdout = mystdout
with Image.open(TEST_PNG_FILE) as im: with Image.open(TEST_PNG_FILE) as im:
im.save(sys.stdout, "PNG") im.save(sys.stdout, "PNG")
@ -784,7 +784,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,16 +368,12 @@ 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() sys.stdout = mystdout # type: ignore[assignment]
else:
mystdout = BytesIO()
sys.stdout = mystdout
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
im.save(sys.stdout, "PPM") im.save(sys.stdout, "PPM")
@ -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

@ -4,7 +4,7 @@ import warnings
import pytest import pytest
from PIL import Image, PsdImagePlugin, UnidentifiedImageError from PIL import Image, PsdImagePlugin
from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy
@ -150,20 +150,26 @@ def test_combined_larger_than_size() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_file,raises", "test_file,raises",
[ [
(
"Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd",
UnidentifiedImageError,
),
(
"Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd",
UnidentifiedImageError,
),
("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError), ("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError),
("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError),
], ],
) )
def test_crashes(test_file: str, raises) -> None: def test_crashes(test_file: str, raises: type[Exception]) -> None:
with open(test_file, "rb") as f: with open(test_file, "rb") as f:
with pytest.raises(raises): with pytest.raises(raises):
with Image.open(f): with Image.open(f):
pass pass
@pytest.mark.parametrize(
"test_file",
[
"Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd",
"Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd",
],
)
def test_layer_crashes(test_file: str) -> None:
with open(test_file, "rb") as f:
with Image.open(f) as im:
with pytest.raises(SyntaxError):
im.layers

View File

@ -105,6 +105,7 @@ def test_load_image_series() -> None:
img_list = SpiderImagePlugin.loadImageSeries(file_list) img_list = SpiderImagePlugin.loadImageSeries(file_list)
# Assert # Assert
assert img_list is not None
assert len(img_list) == 1 assert len(img_list) == 1
assert isinstance(img_list[0], Image.Image) assert isinstance(img_list[0], Image.Image)
assert img_list[0].size == (128, 128) assert img_list[0].size == (128, 128)

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:
@ -621,6 +621,19 @@ class TestFileTiff:
assert_image_equal_tofile(im, tmpfile) assert_image_equal_tofile(im, tmpfile)
def test_iptc(self, tmp_path: Path) -> None:
# Do not preserve IPTC_NAA_CHUNK by default if type is LONG
outfile = str(tmp_path / "temp.tif")
im = hopper()
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd[33723] = 1
ifd.tagtype[33723] = 4
im.tag_v2 = ifd
im.save(outfile)
with Image.open(outfile) as im:
assert 33723 not in im.tag_v2
def test_rowsperstrip(self, tmp_path: Path) -> None: def test_rowsperstrip(self, tmp_path: Path) -> None:
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")
im = hopper() im = hopper()
@ -810,6 +823,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

@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational
from .helper import assert_deep_equal, hopper from .helper import assert_deep_equal, hopper
TAG_IDS = {info.name: info.value for info in TiffTags.TAGS_V2.values()} TAG_IDS: dict[str, int] = {
info.name: info.value
for info in TiffTags.TAGS_V2.values()
if info.value is not None
}
def test_rt_metadata(tmp_path: Path) -> None: def test_rt_metadata(tmp_path: Path) -> None:
@ -411,8 +415,8 @@ def test_empty_values() -> None:
info = TiffImagePlugin.ImageFileDirectory_v2(head) info = TiffImagePlugin.ImageFileDirectory_v2(head)
info.load(data) info.load(data)
# Should not raise ValueError. # Should not raise ValueError.
info = dict(info) info_dict = dict(info)
assert 33432 in info assert 33432 in info_dict
def test_photoshop_info(tmp_path: Path) -> None: def test_photoshop_info(tmp_path: Path) -> None:

View File

@ -5,6 +5,7 @@ import sys
import warnings import warnings
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@ -70,7 +71,9 @@ class TestFileWebp:
# dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm
assert_image_similar_tofile(image, "Tests/images/hopper_webp_bits.ppm", 1.0) assert_image_similar_tofile(image, "Tests/images/hopper_webp_bits.ppm", 1.0)
def _roundtrip(self, tmp_path: Path, mode, epsilon, args={}) -> None: def _roundtrip(
self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {}
) -> None:
temp_file = str(tmp_path / "temp.webp") temp_file = str(tmp_path / "temp.webp")
hopper(mode).save(temp_file, **args) hopper(mode).save(temp_file, **args)

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

@ -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

@ -8,7 +8,7 @@ import sys
import tempfile import tempfile
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import IO from typing import IO, Any
import pytest import pytest
@ -152,7 +152,7 @@ class TestImage:
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:
@ -175,11 +175,19 @@ class TestImage:
def test_fp_name(self, tmp_path: Path) -> None: def test_fp_name(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.jpg") temp_file = str(tmp_path / "temp.jpg")
class FP: class FP(io.BytesIO):
name: str name: str
def write(self, b: bytes) -> None: if sys.version_info >= (3, 12):
pass from collections.abc import Buffer
def write(self, data: Buffer) -> int:
return len(data)
else:
def write(self, data: Any) -> int:
return len(data)
fp = FP() fp = FP()
fp.name = temp_file fp.name = temp_file
@ -393,13 +401,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))
@ -909,6 +917,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

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import TYPE_CHECKING, Any
import pytest import pytest
from packaging.version import parse as parse_version from packaging.version import parse as parse_version
@ -13,13 +13,16 @@ numpy = pytest.importorskip("numpy", reason="NumPy not installed")
im = hopper().resize((128, 100)) im = hopper().resize((128, 100))
if TYPE_CHECKING:
import numpy.typing as npt
def test_toarray() -> None: def test_toarray() -> None:
def test(mode: str) -> tuple[tuple[int, ...], str, int]: def test(mode: str) -> tuple[tuple[int, ...], str, int]:
ai = numpy.array(im.convert(mode)) ai = numpy.array(im.convert(mode))
return ai.shape, ai.dtype.str, ai.nbytes return ai.shape, ai.dtype.str, ai.nbytes
def test_with_dtype(dtype) -> None: def test_with_dtype(dtype: npt.DTypeLike) -> None:
ai = numpy.array(im, dtype=dtype) ai = numpy.array(im, dtype=dtype)
assert ai.dtype == dtype assert ai.dtype == dtype

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

@ -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

@ -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

@ -98,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"))

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

@ -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)

View File

@ -103,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
@ -569,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)
@ -642,7 +642,8 @@ def test_auxiliary_channels_isolated() -> None:
# convert with and without AUX data, test colors are equal # convert with and without AUX data, test colors are equal
src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1]) src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1])
source_profile = ImageCms.createProfile(src_colorSpace) source_profile = ImageCms.createProfile(src_colorSpace)
destination_profile = ImageCms.createProfile(dst_format[1]) 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,

View File

@ -448,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)
@ -469,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)
@ -487,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))
@ -631,6 +634,19 @@ def test_polygon(points: Coords) -> None:
assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon.png") assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon.png")
@pytest.mark.parametrize("points", POINTS)
def test_polygon_width_I16(points: Coords) -> None:
# Arrange
im = Image.new("I;16", (W, H))
draw = ImageDraw.Draw(im)
# Act
draw.polygon(points, outline=0xFFFF, width=2)
# Assert
assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon_width_I.tiff")
@pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("kite_points", KITE_POINTS) @pytest.mark.parametrize("kite_points", KITE_POINTS)
def test_polygon_kite( def test_polygon_kite(
@ -913,7 +929,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)
@ -1429,6 +1450,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)
@ -1467,7 +1489,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(
@ -1477,7 +1499,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)
@ -1562,10 +1587,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
@ -1626,6 +1655,6 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None:
draw.rounded_rectangle(xy) draw.rounded_rectangle(xy)
def test_getdraw(): def test_getdraw() -> None:
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
ImageDraw.getdraw(None, []) ImageDraw.getdraw(None, [])

View File

@ -51,9 +51,18 @@ 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)
def test_mode() -> None:
draw = ImageDraw2.Draw("L", (1, 1))
assert draw.image.mode == "L"
with pytest.raises(ValueError):
ImageDraw2.Draw("L")
@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("bbox", BBOX)

View File

@ -209,7 +209,7 @@ class MockPyDecoder(ImageFile.PyDecoder):
super().__init__(mode, *args) super().__init__(mode, *args)
def decode(self, buffer): def decode(self, buffer: bytes) -> tuple[int, int]:
# eof # eof
return -1, 0 return -1, 0
@ -222,7 +222,7 @@ class MockPyEncoder(ImageFile.PyEncoder):
super().__init__(mode, *args) super().__init__(mode, *args)
def encode(self, buffer): def encode(self, bufsize: int) -> tuple[int, int, bytes]:
return 1, 1, b"" return 1, 1, b""
def cleanup(self) -> None: def cleanup(self) -> None:
@ -351,7 +351,9 @@ class TestPyEncoder(CodecsTest):
ImageFile._save( ImageFile._save(
im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")]
) )
assert MockPyEncoder.last.cleanup_called last: MockPyEncoder | None = MockPyEncoder.last
assert last
assert last.cleanup_called
with pytest.raises(ValueError): with pytest.raises(ValueError):
ImageFile._save( ImageFile._save(
@ -381,7 +383,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

@ -209,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(
@ -224,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
@ -494,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)
@ -548,7 +548,7 @@ 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
): ) -> ImageFont.FreeTypeFont:
_freeTypeFont = getattr(ImageFont, "_FreeTypeFont") _freeTypeFont = getattr(ImageFont, "_FreeTypeFont")
if filepath == path_to_fake: if filepath == path_to_fake:
return _freeTypeFont(FONT_PATH, size, index, encoding, *args) return _freeTypeFont(FONT_PATH, size, index, encoding, *args)
@ -564,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]]]:
@ -1096,6 +1097,23 @@ def test_too_many_characters(font: ImageFont.FreeTypeFont) -> None:
imagefont.getmask("A" * 1_000_001) imagefont.getmask("A" * 1_000_001)
def test_bytes(font: ImageFont.FreeTypeFont) -> None:
assert font.getlength(b"test") == font.getlength("test")
assert font.getbbox(b"test") == font.getbbox("test")
assert_image_equal(
Image.Image()._new(font.getmask(b"test")),
Image.Image()._new(font.getmask("test")),
)
assert_image_equal(
Image.Image()._new(font.getmask2(b"test")[0]),
Image.Image()._new(font.getmask2("test")[0]),
)
assert font.getmask2(b"test")[1] == font.getmask2("test")[1]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_file", "test_file",
[ [

View File

@ -14,7 +14,7 @@ original_core = ImageFont.core
def setup_module() -> None: def setup_module() -> None:
if features.check_module("freetype2"): if features.check_module("freetype2"):
ImageFont.core = _util.DeferredError(ImportError) ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
def teardown_module() -> None: def teardown_module() -> None:
@ -76,3 +76,8 @@ def test_oom() -> None:
font = ImageFont.ImageFont() font = ImageFont.ImageFont()
font._load_pilfont_data(fp, Image.new("L", (1, 1))) font._load_pilfont_data(fp, Image.new("L", (1, 1)))
font.getmask("A" * 1_000_000) font.getmask("A" * 1_000_000)
def test_freetypefont_without_freetype() -> None:
with pytest.raises(ImportError):
ImageFont.truetype("Tests/fonts/FreeMono.ttf")

View File

@ -89,6 +89,7 @@ $bmp = New-Object Drawing.Bitmap 200, 200
p.communicate() p.communicate()
im = ImageGrab.grabclipboard() im = ImageGrab.grabclipboard()
assert isinstance(im, list)
assert len(im) == 1 assert len(im) == 1
assert os.path.samefile(im[0], "Tests/images/hopper.gif") assert os.path.samefile(im[0], "Tests/images/hopper.gif")
@ -105,6 +106,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes)
p.communicate() p.communicate()
im = ImageGrab.grabclipboard() im = ImageGrab.grabclipboard()
assert isinstance(im, Image.Image)
assert_image_equal_tofile(im, "Tests/images/hopper.png") assert_image_equal_tofile(im, "Tests/images/hopper.png")
@pytest.mark.skipif( @pytest.mark.skipif(
@ -120,6 +122,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes)
with open(image_path, "rb") as fp: with open(image_path, "rb") as fp:
subprocess.call(["wl-copy"], stdin=fp) subprocess.call(["wl-copy"], stdin=fp)
im = ImageGrab.grabclipboard() im = ImageGrab.grabclipboard()
assert isinstance(im, Image.Image)
assert_image_equal_tofile(im, image_path) assert_image_equal_tofile(im, image_path)
@pytest.mark.skipif( @pytest.mark.skipif(

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

@ -101,7 +101,7 @@ def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
assert i.im.getpixel((x, y))[c] >= 250 assert i.im.getpixel((x, y))[c] >= 250
# Fuzzy match. # Fuzzy match.
def gp(x, y): def gp(x: int, y: int) -> tuple[int, ...]:
return i.im.getpixel((x, y)) return i.im.getpixel((x, y))
assert 236 <= gp(7, 4)[0] <= 239 assert 236 <= gp(7, 4)[0] <= 239

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:
@ -88,13 +88,13 @@ def test_file(tmp_path: Path) -> None:
palette.save(f) palette.save(f)
p = ImagePalette.load(f) lut = ImagePalette.load(f)
# load returns raw palette information # load returns raw palette information
assert len(p[0]) == 768 assert len(lut[0]) == 768
assert p[1] == "RGB" assert lut[1] == "RGB"
p = ImagePalette.raw(p[1], p[0]) p = ImagePalette.raw(lut[1], lut[0])
assert isinstance(p, ImagePalette.ImagePalette) assert isinstance(p, ImagePalette.ImagePalette)
assert p.palette == palette.tobytes() assert p.palette == palette.tobytes()

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

@ -45,21 +45,22 @@ if is_win32():
memcpy = ctypes.cdll.msvcrt.memcpy memcpy = ctypes.cdll.msvcrt.memcpy
memcpy.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] memcpy.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t]
CreateCompatibleDC = ctypes.windll.gdi32.CreateCompatibleDC windll = getattr(ctypes, "windll")
CreateCompatibleDC = windll.gdi32.CreateCompatibleDC
CreateCompatibleDC.argtypes = [ctypes.wintypes.HDC] CreateCompatibleDC.argtypes = [ctypes.wintypes.HDC]
CreateCompatibleDC.restype = ctypes.wintypes.HDC CreateCompatibleDC.restype = ctypes.wintypes.HDC
DeleteDC = ctypes.windll.gdi32.DeleteDC DeleteDC = windll.gdi32.DeleteDC
DeleteDC.argtypes = [ctypes.wintypes.HDC] DeleteDC.argtypes = [ctypes.wintypes.HDC]
SelectObject = ctypes.windll.gdi32.SelectObject SelectObject = windll.gdi32.SelectObject
SelectObject.argtypes = [ctypes.wintypes.HDC, ctypes.wintypes.HGDIOBJ] SelectObject.argtypes = [ctypes.wintypes.HDC, ctypes.wintypes.HGDIOBJ]
SelectObject.restype = ctypes.wintypes.HGDIOBJ SelectObject.restype = ctypes.wintypes.HGDIOBJ
DeleteObject = ctypes.windll.gdi32.DeleteObject DeleteObject = windll.gdi32.DeleteObject
DeleteObject.argtypes = [ctypes.wintypes.HGDIOBJ] DeleteObject.argtypes = [ctypes.wintypes.HGDIOBJ]
CreateDIBSection = ctypes.windll.gdi32.CreateDIBSection CreateDIBSection = windll.gdi32.CreateDIBSection
CreateDIBSection.argtypes = [ CreateDIBSection.argtypes = [
ctypes.wintypes.HDC, ctypes.wintypes.HDC,
ctypes.c_void_p, ctypes.c_void_p,
@ -70,7 +71,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,16 +54,12 @@ 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() sys.stdout = mystdout # type: ignore[assignment]
else:
mystdout = BytesIO()
sys.stdout = mystdout
ps = PSDraw.PSDraw() ps = PSDraw.PSDraw()
_create_document(ps) _create_document(ps)
@ -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

@ -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

@ -286,7 +286,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica
offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset of the text, while the new ``bbox`` methods distinguish this as a ``top``
offset. offset.
.. image:: ./example/size_vs_bbox.png .. image:: ./example/size_vs_bbox.webp
:alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself.
:align: center :align: center

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -26,5 +26,5 @@ if __name__ == "__main__":
d.line(((x * 200, y * 100), (x * 200, (y + 1) * 100)), "black", 3) d.line(((x * 200, y * 100), (x * 200, (y + 1) * 100)), "black", 3)
if y != 0: if y != 0:
d.line(((x * 200, y * 100), ((x + 1) * 200, y * 100)), "black", 3) d.line(((x * 200, y * 100), ((x + 1) * 200, y * 100)), "black", 3)
im.save("docs/example/anchors.png") im.save("docs/example/anchors.webp")
im.show() im.show()

BIN
docs/example/anchors.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -132,7 +132,7 @@ of the two lines.
.. comment: Image generated with ../example/anchors.py .. comment: Image generated with ../example/anchors.py
.. image:: ../example/anchors.png .. image:: ../example/anchors.webp
:alt: Text anchor examples :alt: Text anchor examples
:align: center :align: center

View File

@ -278,26 +278,26 @@ choose to resize relative to a given size.
from PIL import Image, ImageOps from PIL import Image, ImageOps
size = (100, 150) size = (100, 150)
with Image.open("Tests/images/hopper.png") as im: with Image.open("Tests/images/hopper.webp") as im:
ImageOps.contain(im, size).save("imageops_contain.png") ImageOps.contain(im, size).save("imageops_contain.webp")
ImageOps.cover(im, size).save("imageops_cover.png") ImageOps.cover(im, size).save("imageops_cover.webp")
ImageOps.fit(im, size).save("imageops_fit.png") ImageOps.fit(im, size).save("imageops_fit.webp")
ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp")
# thumbnail() can also be used, # thumbnail() can also be used,
# but will modify the image object in place # but will modify the image object in place
im.thumbnail(size) im.thumbnail(size)
im.save("imageops_thumbnail.png") im.save("image_thumbnail.webp")
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | | | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` |
+================+===========================================+============================================+==========================================+========================================+========================================+ +================+============================================+=============================================+===========================================+=========================================+=========================================+
|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | |Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | |Resulting image | .. image:: ../example/image_thumbnail.webp | .. image:: ../example/imageops_contain.webp | .. image:: ../example/imageops_cover.webp | .. image:: ../example/imageops_fit.webp | .. image:: ../example/imageops_pad.webp |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | |Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
.. _color-transforms: .. _color-transforms:

View File

@ -195,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

View File

@ -691,23 +691,7 @@ Methods
:param hints: An optional list of hints. :param hints: An optional list of hints.
:returns: A (drawing context, drawing resource factory) tuple. :returns: A (drawing context, drawing resource factory) tuple.
.. py:method:: floodfill(image, xy, value, border=None, thresh=0) .. autofunction:: PIL.ImageDraw.floodfill
.. warning:: This method is experimental.
Fills a bounded region with a given color.
:param image: Target image.
:param xy: Seed position (a 2-item coordinate tuple).
:param value: Fill color.
:param border: Optional border value. If given, the region consists of
pixels with a color different from the border color. If not given,
the region consists of pixels having the same color as the seed
pixel.
:param thresh: Optional threshold value which specifies a maximum
tolerable difference of a pixel value from the 'background' in
order for it to be replaced. Useful for filling regions of non-
homogeneous, but similar, colors.
.. _BCP 47 language code: https://www.w3.org/International/articles/language-tags/ .. _BCP 47 language code: https://www.w3.org/International/articles/language-tags/
.. _OpenType docs: https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist .. _OpenType docs: https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist

View File

@ -36,26 +36,26 @@ Resize relative to a given size
from PIL import Image, ImageOps from PIL import Image, ImageOps
size = (100, 150) size = (100, 150)
with Image.open("Tests/images/hopper.png") as im: with Image.open("Tests/images/hopper.webp") as im:
ImageOps.contain(im, size).save("imageops_contain.png") ImageOps.contain(im, size).save("imageops_contain.webp")
ImageOps.cover(im, size).save("imageops_cover.png") ImageOps.cover(im, size).save("imageops_cover.webp")
ImageOps.fit(im, size).save("imageops_fit.png") ImageOps.fit(im, size).save("imageops_fit.webp")
ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp")
# thumbnail() can also be used, # thumbnail() can also be used,
# but will modify the image object in place # but will modify the image object in place
im.thumbnail(size) im.thumbnail(size)
im.save("imageops_thumbnail.png") im.save("image_thumbnail.webp")
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | | | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` |
+================+===========================================+============================================+==========================================+========================================+========================================+ +================+============================================+=============================================+===========================================+=========================================+=========================================+
|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | |Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | |Resulting image | .. image:: ../example/image_thumbnail.webp | .. image:: ../example/imageops_contain.webp | .. image:: ../example/imageops_cover.webp | .. image:: ../example/imageops_fit.webp | .. image:: ../example/imageops_pad.webp |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | |Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
.. autofunction:: contain .. autofunction:: contain
.. autofunction:: cover .. autofunction:: cover

View File

@ -44,42 +44,23 @@ Access using negative indexes is also possible. ::
----------------------------- -----------------------------
.. class:: PixelAccess .. class:: PixelAccess
:canonical: PIL.Image.core.PixelAccess
.. method:: __setitem__(self, xy, color): .. method:: __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]
Modifies the pixel at x,y. The color is given as a single Returns the pixel at x,y. The pixel is returned as a single
numerical value for single band images, and a tuple for value for single band images or a tuple for multi-band images.
multi-band images
:param xy: The pixel coordinate, given as (x, y).
:param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode)
.. method:: __getitem__(self, xy):
Returns the pixel at x,y. The pixel is returned as a single
value for single band images or a tuple for multiple band
images
:param xy: The pixel coordinate, given as (x, y). :param xy: The pixel coordinate, given as (x, y).
:returns: a pixel value for single band images, a tuple of :returns: a pixel value for single band images, a tuple of
pixel values for multiband images. pixel values for multiband images.
.. method:: putpixel(self, xy, color): .. method:: __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None
Modifies the pixel at x,y. The color is given as a single Modifies the pixel at x,y. The color is given as a single
numerical value for single band images, and a tuple for numerical value for single band images, and a tuple for
multi-band images. In addition to this, RGB and RGBA tuples multi-band images.
are accepted for P and PA images.
:param xy: The pixel coordinate, given as (x, y). :param xy: The pixel coordinate, given as (x, y).
:param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode) :param color: The pixel value according to its mode,
e.g. tuple (r, g, b) for RGB mode.
.. method:: getpixel(self, xy):
Returns the pixel at x,y. The pixel is returned as a single
value for single band images or a tuple for multiple band
images
:param xy: The pixel coordinate, given as (x, y).
:returns: a pixel value for single band images, a tuple of
pixel values for multiband images.

View File

@ -44,3 +44,4 @@ Access using negative indexes is also possible. ::
.. autoclass:: PIL.PyAccess.PyAccess() .. autoclass:: PIL.PyAccess.PyAccess()
:members: :members:
:special-members: __getitem__, __setitem__

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

@ -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

@ -98,7 +98,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica
offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset of the text, while the new ``bbox`` methods distinguish this as a ``top``
offset. offset.
.. image:: ../example/size_vs_bbox.png .. image:: ../example/size_vs_bbox.webp
:alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself.
:align: center :align: center

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

@ -430,6 +430,7 @@ class BLPEncoder(ImageFile.PyEncoder):
def _write_palette(self) -> bytes: def _write_palette(self) -> bytes:
data = b"" data = b""
assert self.im is not None
palette = self.im.getpalette("RGBA", "RGBA") palette = self.im.getpalette("RGBA", "RGBA")
for i in range(len(palette) // 4): for i in range(len(palette) // 4):
r, g, b, a = palette[i * 4 : (i + 1) * 4] r, g, b, a = palette[i * 4 : (i + 1) * 4]
@ -444,6 +445,7 @@ class BLPEncoder(ImageFile.PyEncoder):
offset = 20 + 16 * 4 * 2 + len(palette_data) offset = 20 + 16 * 4 * 2 + len(palette_data)
data = struct.pack("<16I", offset, *((0,) * 15)) data = struct.pack("<16I", offset, *((0,) * 15))
assert self.im is not None
w, h = self.im.size w, h = self.im.size
data += struct.pack("<16I", w * h, *((0,) * 15)) data += struct.pack("<16I", w * h, *((0,) * 15))

View File

@ -380,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:

View File

@ -230,6 +230,11 @@ class EpsImageFile(ImageFile.ImageFile):
trailer_reached = False trailer_reached = False
def check_required_header_comments() -> None: def check_required_header_comments() -> None:
"""
The EPS specification requires that some headers exist.
This should be checked when the header comments formally end,
when image data starts, or when the file ends, whichever comes first.
"""
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)
@ -270,6 +275,8 @@ class EpsImageFile(ImageFile.ImageFile):
if byte == b"": if byte == b"":
# if we didn't read a byte we must be at the end of the file # if we didn't read a byte we must be at the end of the file
if bytes_read == 0: if bytes_read == 0:
if reading_header_comments:
check_required_header_comments()
break break
elif byte in b"\r\n": elif byte in b"\r\n":
# if we read a line ending character, ignore it and parse what # if we read a line ending character, ignore it and parse what
@ -365,8 +372,6 @@ class EpsImageFile(ImageFile.ImageFile):
trailer_reached = True trailer_reached = True
bytes_read = 0 bytes_read = 0
check_required_header_comments()
if not self._size: if not self._size:
msg = "cannot determine EPS bounding box" msg = "cannot determine EPS bounding box"
raise OSError(msg) raise OSError(msg)

View File

@ -115,7 +115,11 @@ 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

View File

@ -432,7 +432,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
@ -457,6 +457,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

View File

@ -79,7 +79,7 @@ OPEN = {
"LA image": ("LA", "LA;L"), "LA image": ("LA", "LA;L"),
"PA image": ("LA", "PA;L"), "PA image": ("LA", "PA;L"),
"RGBA image": ("RGBA", "RGBA;L"), "RGBA image": ("RGBA", "RGBA;L"),
"RGBX image": ("RGBX", "RGBX;L"), "RGBX image": ("RGB", "RGBX;L"),
"CMYK image": ("CMYK", "CMYK;L"), "CMYK image": ("CMYK", "CMYK;L"),
"YCC image": ("YCbCr", "YCbCr;L"), "YCC image": ("YCbCr", "YCbCr;L"),
} }

View File

@ -41,7 +41,16 @@ 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, Tuple, 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.
@ -218,7 +227,7 @@ if hasattr(core, "DEFAULT_STRATEGY"):
# Registries # Registries
if TYPE_CHECKING: if TYPE_CHECKING:
from . import ImageFile from . import ImageFile, PyAccess
ID: list[str] = [] ID: list[str] = []
OPEN: dict[ OPEN: dict[
str, str,
@ -410,7 +419,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 +444,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 = ()
@ -550,10 +563,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
@ -687,7 +700,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.
@ -700,14 +713,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
@ -754,7 +767,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.
@ -776,12 +789,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()
@ -789,7 +803,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
@ -832,7 +846,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.
@ -843,16 +859,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)
@ -863,7 +880,7 @@ class Image:
msg = "cannot decode image data" msg = "cannot decode image data"
raise ValueError(msg) raise ValueError(msg)
def load(self): def load(self) -> core.PixelAccess | PyAccess.PyAccess | None:
""" """
Allocates storage for the image and loads the pixel data. In Allocates storage for the image and loads the pixel data. In
normal cases, you don't need to call this method, since the normal cases, you don't need to call this method, since the
@ -876,12 +893,12 @@ class Image:
operations. See :ref:`file-handling` for more information. operations. See :ref:`file-handling` for more information.
:returns: An image access object. :returns: An image access object.
:rtype: :ref:`PixelAccess` or :py:class:`PIL.PyAccess` :rtype: :py:class:`.PixelAccess` or :py:class:`.PyAccess`
""" """
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"):
@ -891,9 +908,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:
@ -905,6 +922,7 @@ class Image:
if self.pyaccess: if self.pyaccess:
return self.pyaccess return self.pyaccess
return self.im.pixel_access(self.readonly) return self.im.pixel_access(self.readonly)
return None
def verify(self) -> None: def verify(self) -> None:
""" """
@ -996,9 +1014,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)
@ -1092,7 +1112,10 @@ class Image:
del new_im.info["transparency"] del new_im.info["transparency"]
if trns is not None: if trns is not None:
try: try:
new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) new_im.info["transparency"] = new_im.palette.getcolor(
cast(Tuple[int, int, int], trns), # trns was converted to RGB
new_im,
)
except Exception: except Exception:
# if we can't make a transparent color, don't leave the old # if we can't make a transparent color, don't leave the old
# transparency hanging around to mess us up. # transparency hanging around to mess us up.
@ -1142,7 +1165,10 @@ class Image:
if trns is not None: if trns is not None:
if new_im.mode == "P": if new_im.mode == "P":
try: try:
new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) new_im.info["transparency"] = new_im.palette.getcolor(
cast(Tuple[int, int, int], trns), # trns was converted to RGB
new_im,
)
except ValueError as e: except ValueError as e:
del new_im.info["transparency"] del new_im.info["transparency"]
if str(e) != "cannot allocate more than 256 colors": if str(e) != "cannot allocate more than 256 colors":
@ -1158,7 +1184,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,
@ -1250,7 +1276,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
@ -1276,7 +1302,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.
@ -1297,7 +1325,7 @@ class Image:
return im.crop((x0, y0, x1, y1)) return im.crop((x0, y0, x1, y1))
def draft( def draft(
self, mode: str | None, 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
@ -1447,8 +1475,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):
@ -1474,9 +1509,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:
""" """
@ -1549,7 +1585,11 @@ class Image:
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()
@ -1633,7 +1673,9 @@ class Image:
del self.info["transparency"] del self.info["transparency"]
def getpixel(self, xy): def getpixel(
self, xy: tuple[int, int] | list[int]
) -> float | tuple[int, ...] | None:
""" """
Returns the pixel value at a given position. Returns the pixel value at a given position.
@ -1720,7 +1762,7 @@ class Image:
def paste( def paste(
self, self,
im: Image | str | float | tuple[float, ...], im: Image | str | float | tuple[float, ...],
box: tuple[int, int, int, int] | tuple[int, int] | None = None, box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None,
mask: Image | None = None, mask: Image | None = None,
) -> None: ) -> None:
""" """
@ -1762,10 +1804,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)
@ -1803,7 +1849,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.
@ -1818,32 +1866,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:
@ -1854,7 +1905,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.
@ -1891,7 +1946,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
@ -1899,18 +1956,17 @@ 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: Image | int) -> None:
""" """
Adds or replaces the alpha layer in this image. If the image Adds or replaces the alpha layer in this image. If the image
does not have an alpha layer, it's converted to "LA" or "RGBA". does not have an alpha layer, it's converted to "LA" or "RGBA".
The new layer must be either "L" or "1". The new layer must be either "L" or "1".
:param alpha: The new alpha layer. This can either be an "L" or "1" :param alpha: The new alpha layer. This can either be an "L" or "1"
image having the same size as this image, or an integer or image having the same size as this image, or an integer.
other color value.
""" """
self._ensure_mutable() self._ensure_mutable()
@ -1949,6 +2005,7 @@ class Image:
alpha = alpha.convert("L") alpha = alpha.convert("L")
else: else:
# constant alpha # constant alpha
alpha = cast(int, alpha) # see python/typing#1013
try: try:
self.im.fillband(band, alpha) self.im.fillband(band, alpha)
except (AttributeError, ValueError): except (AttributeError, ValueError):
@ -1960,7 +2017,10 @@ class Image:
self.im.putband(alpha.im, band) self.im.putband(alpha.im, band)
def putdata( def putdata(
self, data: Sequence[float], scale: float = 1.0, offset: float = 0.0 self,
data: Sequence[float] | Sequence[Sequence[int]],
scale: float = 1.0,
offset: float = 0.0,
) -> None: ) -> 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
@ -2011,10 +2071,12 @@ 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: tuple[int, int], value: float | tuple[int, ...] | list[int]
) -> None:
""" """
Modifies the pixel at the given position. The color is given as Modifies the pixel at the given position. The color is given as
a single numerical value for single-band images, and a tuple for a single numerical value for single-band images, and a tuple for
@ -2052,9 +2114,8 @@ class Image:
if self.mode == "PA": if self.mode == "PA":
alpha = value[3] if len(value) == 4 else 255 alpha = value[3] if len(value) == 4 else 255
value = value[:3] value = value[:3]
value = self.palette.getcolor(value, self) palette_index = self.palette.getcolor(value, self)
if self.mode == "PA": value = (palette_index, alpha) if self.mode == "PA" else palette_index
value = (value, alpha)
return self.im.putpixel(xy, value) return self.im.putpixel(xy, value)
def remap_palette(self, dest_map, source_palette=None): def remap_palette(self, dest_map, source_palette=None):
@ -2126,7 +2187,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")
@ -2307,7 +2368,7 @@ class Image:
angle: float, angle: float,
resample: Resampling = Resampling.NEAREST, resample: Resampling = Resampling.NEAREST,
expand: int | bool = False, expand: int | bool = False,
center: tuple[int, int] | None = None, center: tuple[float, float] | None = None,
translate: tuple[int, int] | None = None, translate: tuple[int, int] | None = None,
fillcolor: float | tuple[float, ...] | str | None = None, fillcolor: float | tuple[float, ...] | str | None = None,
) -> Image: ) -> Image:
@ -2375,10 +2436,7 @@ class Image:
else: else:
post_trans = translate post_trans = translate
if center is None: if center is None:
# FIXME These should be rounded to ints? center = (w / 2, h / 2)
rotn_center = (w / 2.0, h / 2.0)
else:
rotn_center = center
angle = -math.radians(angle) angle = -math.radians(angle)
matrix = [ matrix = [
@ -2395,10 +2453,10 @@ class Image:
return a * x + b * y + c, d * x + e * y + f return a * x + b * y + c, d * x + e * y + f
matrix[2], matrix[5] = transform( matrix[2], matrix[5] = transform(
-rotn_center[0] - post_trans[0], -rotn_center[1] - post_trans[1], matrix -center[0] - post_trans[0], -center[1] - post_trans[1], matrix
) )
matrix[2] += rotn_center[0] matrix[2] += center[0]
matrix[5] += rotn_center[1] matrix[5] += center[1]
if expand: if expand:
# calculate output size # calculate output size
@ -2638,7 +2696,7 @@ class Image:
self, self,
size: tuple[float, float], size: tuple[float, float],
resample: Resampling = Resampling.BICUBIC, resample: Resampling = Resampling.BICUBIC,
reducing_gap: float = 2.0, reducing_gap: float | None = 2.0,
) -> None: ) -> None:
""" """
Make this image into a thumbnail. This method modifies the Make this image into a thumbnail. This method modifies the
@ -2699,11 +2757,12 @@ 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:
preserved_size = preserve_aspect_ratio() preserved_size = preserve_aspect_ratio()
if preserved_size is None: if preserved_size is None:
return return
size = preserved_size final_size = preserved_size
res = self.draft( res = self.draft(
None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap))
@ -2717,13 +2776,13 @@ class Image:
preserved_size = preserve_aspect_ratio() preserved_size = preserve_aspect_ratio()
if preserved_size is None: if preserved_size is None:
return return
size = preserved_size 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
@ -2990,29 +3049,29 @@ def _wedge() -> Image:
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.
@ -3061,7 +3120,13 @@ def new(
return im._new(core.fill(mode, size, color)) return im._new(core.fill(mode, size, color))
def frombytes(mode, size, data, decoder_name: str = "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.
@ -3089,18 +3154,21 @@ def frombytes(mode, size, data, decoder_name: str = "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: str, size, data, decoder_name: str = "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.
@ -3557,7 +3625,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:
@ -3691,7 +3759,7 @@ def _show(image: Image, **options: Any) -> None:
def effect_mandelbrot( def effect_mandelbrot(
size: tuple[int, int], extent: tuple[int, int, int, int], quality: int size: tuple[int, int], extent: tuple[float, float, float, float], quality: int
) -> Image: ) -> Image:
""" """
Generate a Mandelbrot set covering the given extent. Generate a Mandelbrot set covering the given extent.
@ -3738,19 +3806,18 @@ def radial_gradient(mode: str) -> Image:
# 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)]:
@ -3759,13 +3826,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

@ -34,12 +34,25 @@ from __future__ import annotations
import math import math
import numbers import numbers
import struct import struct
from typing import TYPE_CHECKING, AnyStr, 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 ._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>
@ -49,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:
""" """
@ -93,9 +108,6 @@ 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:
from . import ImageFont
def getfont( def getfont(
self, self,
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
@ -133,34 +145,47 @@ class ImageDraw:
else: else:
return self.getfont() return self.getfont()
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)
@ -169,30 +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 circle( def circle(
self, xy: Sequence[float], radius: float, fill=None, outline=None, width=1 self,
xy: Sequence[float],
radius: float,
fill: _Ink | None = None,
outline: _Ink | None = None,
width: int = 1,
) -> None: ) -> None:
"""Draw a circle given center coordinates and a radius.""" """Draw a circle given center coordinates and a radius."""
ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
self.ellipse(ellipse_xy, fill, outline, width) self.ellipse(ellipse_xy, fill, outline, width)
def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: 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:
@ -222,7 +272,7 @@ class ImageDraw:
def coord_at_angle( def coord_at_angle(
coord: Sequence[float], angle: float coord: Sequence[float], angle: float
) -> tuple[float, float]: ) -> tuple[float, ...]:
x, y = coord x, y = coord
angle -= 90 angle -= 90
distance = width / 2 - 1 distance = width / 2 - 1
@ -263,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:
@ -319,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)):
@ -376,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
@ -409,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:
@ -529,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:
@ -879,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.
@ -891,19 +978,14 @@ 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):
""" """
:param im: The image to draw in. :param im: The image to draw in.
:param hints: An optional list of hints. Deprecated. :param hints: An optional list of hints. Deprecated.
@ -913,9 +995,8 @@ def getdraw(im=None, hints=None):
deprecate("'hints' parameter", 12) deprecate("'hints' parameter", 12)
from . import ImageDraw2 from . import ImageDraw2
if im: draw = ImageDraw2.Draw(im) if im is not None else None
im = ImageDraw2.Draw(im) return draw, ImageDraw2
return im, ImageDraw2
def floodfill( def floodfill(
@ -926,7 +1007,9 @@ def floodfill(
thresh: float = 0, thresh: float = 0,
) -> None: ) -> None:
""" """
(experimental) Fills a bounded region with a given color. .. warning:: This method is experimental.
Fills a bounded region with a given color.
:param image: Target image. :param image: Target image.
:param xy: Seed position (a 2-item coordinate tuple). See :param xy: Seed position (a 2-item coordinate tuple). See
@ -944,6 +1027,7 @@ def floodfill(
# based on an implementation by Eric S. Raymond # based on an implementation by Eric S. Raymond
# amended by yo1995 @20180806 # amended by yo1995 @20180806
pixel = image.load() pixel = image.load()
assert pixel is not None
x, y = xy x, y = xy
try: try:
background = pixel[x, y] background = pixel[x, y]
@ -981,12 +1065,12 @@ def floodfill(
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
@ -1024,7 +1108,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"
@ -1036,9 +1120,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 "
@ -1046,25 +1145,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))
@ -1080,7 +1171,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)

View File

@ -24,7 +24,10 @@
""" """
from __future__ import annotations from __future__ import annotations
from typing import BinaryIO
from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
from ._typing import StrOrBytesPath
class Pen: class Pen:
@ -45,7 +48,9 @@ class Brush:
class Font: class Font:
"""Stores a TrueType font and color""" """Stores a TrueType font and color"""
def __init__(self, color, file, size=12): def __init__(
self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12
) -> None:
# FIXME: add support for bitmap fonts # FIXME: add support for bitmap fonts
self.color = ImageColor.getrgb(color) self.color = ImageColor.getrgb(color)
self.font = ImageFont.truetype(file, size) self.font = ImageFont.truetype(file, size)
@ -56,8 +61,16 @@ class Draw:
(Experimental) WCK-style drawing interface (Experimental) WCK-style drawing interface
""" """
def __init__(self, image, size=None, color=None): def __init__(
if not hasattr(image, "im"): self,
image: Image.Image | str,
size: tuple[int, int] | list[int] | None = None,
color: float | tuple[float, ...] | str | None = None,
) -> None:
if isinstance(image, str):
if size is None:
msg = "If image argument is mode string, size must be a list or tuple"
raise ValueError(msg)
image = Image.new(image, size, color) image = Image.new(image, size, color)
self.draw = ImageDraw.Draw(image) self.draw = ImageDraw.Draw(image)
self.image = image self.image = image

View File

@ -65,7 +65,7 @@ Dict of known error codes returned from :meth:`.PyDecoder.decode`,
# Helpers # Helpers
def _get_oserror(error, *, encoder): def _get_oserror(error: int, *, encoder: bool) -> OSError:
try: try:
msg = Image.core.getcodecstatus(error) msg = Image.core.getcodecstatus(error)
except AttributeError: except AttributeError:
@ -76,7 +76,7 @@ def _get_oserror(error, *, encoder):
return OSError(msg) return OSError(msg)
def raise_oserror(error): def raise_oserror(error: int) -> OSError:
deprecate( deprecate(
"raise_oserror", "raise_oserror",
12, 12,
@ -154,11 +154,12 @@ class ImageFile(Image.Image):
self.fp.close() self.fp.close()
raise raise
def get_format_mimetype(self): def get_format_mimetype(self) -> str | None:
if self.custom_mimetype: if self.custom_mimetype:
return self.custom_mimetype return self.custom_mimetype
if self.format is not None: if self.format is not None:
return Image.MIME.get(self.format.upper()) return Image.MIME.get(self.format.upper())
return None
def __setstate__(self, state): def __setstate__(self, state):
self.tile = [] self.tile = []
@ -365,7 +366,7 @@ class StubImageFile(ImageFile):
certain format, but relies on external code to load the file. certain format, but relies on external code to load the file.
""" """
def _open(self): def _open(self) -> None:
msg = "StubImageFile subclass must implement _open" msg = "StubImageFile subclass must implement _open"
raise NotImplementedError(msg) raise NotImplementedError(msg)
@ -381,7 +382,7 @@ class StubImageFile(ImageFile):
self.__dict__ = image.__dict__ self.__dict__ = image.__dict__
return image.load() return image.load()
def _load(self): def _load(self) -> StubHandler | None:
"""(Hook) Find actual image loader.""" """(Hook) Find actual image loader."""
msg = "StubImageFile subclass must implement _load" msg = "StubImageFile subclass must implement _load"
raise NotImplementedError(msg) raise NotImplementedError(msg)
@ -621,7 +622,7 @@ class PyCodecState:
self.xoff = 0 self.xoff = 0
self.yoff = 0 self.yoff = 0
def extents(self): def extents(self) -> tuple[int, int, int, int]:
return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize
@ -661,7 +662,7 @@ class PyCodec:
""" """
self.fd = fd self.fd = fd
def setimage(self, im, extents=None): def setimage(self, im, extents: tuple[int, int, int, int] | None = None) -> None:
""" """
Called from ImageFile to set the core output image for the codec Called from ImageFile to set the core output image for the codec
@ -710,10 +711,10 @@ class PyDecoder(PyCodec):
_pulls_fd = False _pulls_fd = False
@property @property
def pulls_fd(self): def pulls_fd(self) -> bool:
return self._pulls_fd return self._pulls_fd
def decode(self, buffer): def decode(self, buffer: bytes) -> tuple[int, int]:
""" """
Override to perform the decoding process. Override to perform the decoding process.
@ -738,6 +739,7 @@ class PyDecoder(PyCodec):
if not rawmode: if not rawmode:
rawmode = self.mode rawmode = self.mode
d = Image._getdecoder(self.mode, "raw", rawmode) d = Image._getdecoder(self.mode, "raw", rawmode)
assert self.im is not None
d.setimage(self.im, self.state.extents()) d.setimage(self.im, self.state.extents())
s = d.decode(data) s = d.decode(data)
@ -760,7 +762,7 @@ class PyEncoder(PyCodec):
_pushes_fd = False _pushes_fd = False
@property @property
def pushes_fd(self): def pushes_fd(self) -> bool:
return self._pushes_fd return self._pushes_fd
def encode(self, bufsize: int) -> tuple[int, int, bytes]: def encode(self, bufsize: int) -> tuple[int, int, bytes]:
@ -775,7 +777,7 @@ class PyEncoder(PyCodec):
msg = "unavailable in base encoder" msg = "unavailable in base encoder"
raise NotImplementedError(msg) raise NotImplementedError(msg)
def encode_to_pyfd(self): def encode_to_pyfd(self) -> tuple[int, int]:
""" """
If ``pushes_fd`` is ``True``, then this method will be used, If ``pushes_fd`` is ``True``, then this method will be used,
and ``encode()`` will only be called once. and ``encode()`` will only be called once.
@ -787,6 +789,7 @@ class PyEncoder(PyCodec):
return 0, -8 # bad configuration return 0, -8 # bad configuration
bytes_consumed, errcode, data = self.encode(0) bytes_consumed, errcode, data = self.encode(0)
if data: if data:
assert self.fd is not None
self.fd.write(data) self.fd.write(data)
return bytes_consumed, errcode return bytes_consumed, errcode

View File

@ -19,12 +19,16 @@ from __future__ import annotations
import abc import abc
import functools import functools
from types import ModuleType from types import ModuleType
from typing import Any, Sequence from typing import TYPE_CHECKING, Any, Callable, Sequence, cast
if TYPE_CHECKING:
from . import _imaging
from ._typing import NumpyArray
class Filter: class Filter:
@abc.abstractmethod @abc.abstractmethod
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
pass pass
@ -33,7 +37,9 @@ class MultibandFilter(Filter):
class BuiltinFilter(MultibandFilter): class BuiltinFilter(MultibandFilter):
def filter(self, image): filterargs: tuple[Any, ...]
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
if image.mode == "P": if image.mode == "P":
msg = "cannot filter palette images" msg = "cannot filter palette images"
raise ValueError(msg) raise ValueError(msg)
@ -91,7 +97,7 @@ class RankFilter(Filter):
self.size = size self.size = size
self.rank = rank self.rank = rank
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
if image.mode == "P": if image.mode == "P":
msg = "cannot filter palette images" msg = "cannot filter palette images"
raise ValueError(msg) raise ValueError(msg)
@ -158,7 +164,7 @@ class ModeFilter(Filter):
def __init__(self, size: int = 3) -> None: def __init__(self, size: int = 3) -> None:
self.size = size self.size = size
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
return image.modefilter(self.size) return image.modefilter(self.size)
@ -176,9 +182,9 @@ class GaussianBlur(MultibandFilter):
def __init__(self, radius: float | Sequence[float] = 2) -> None: def __init__(self, radius: float | Sequence[float] = 2) -> None:
self.radius = radius self.radius = radius
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
xy = self.radius xy = self.radius
if not isinstance(xy, (tuple, list)): if isinstance(xy, (int, float)):
xy = (xy, xy) xy = (xy, xy)
if xy == (0, 0): if xy == (0, 0):
return image.copy() return image.copy()
@ -208,9 +214,9 @@ class BoxBlur(MultibandFilter):
raise ValueError(msg) raise ValueError(msg)
self.radius = radius self.radius = radius
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
xy = self.radius xy = self.radius
if not isinstance(xy, (tuple, list)): if isinstance(xy, (int, float)):
xy = (xy, xy) xy = (xy, xy)
if xy == (0, 0): if xy == (0, 0):
return image.copy() return image.copy()
@ -241,7 +247,7 @@ class UnsharpMask(MultibandFilter):
self.percent = percent self.percent = percent
self.threshold = threshold self.threshold = threshold
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
return image.unsharp_mask(self.radius, self.percent, self.threshold) return image.unsharp_mask(self.radius, self.percent, self.threshold)
@ -387,8 +393,13 @@ class Color3DLUT(MultibandFilter):
name = "Color 3D LUT" name = "Color 3D LUT"
def __init__( def __init__(
self, size, table, channels: int = 3, target_mode: str | None = None, **kwargs self,
): size: int | tuple[int, int, int],
table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray,
channels: int = 3,
target_mode: str | None = None,
**kwargs: bool,
) -> None:
if channels not in (3, 4): if channels not in (3, 4):
msg = "Only 3 or 4 output channels are supported" msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg) raise ValueError(msg)
@ -410,15 +421,16 @@ class Color3DLUT(MultibandFilter):
pass pass
if numpy and isinstance(table, numpy.ndarray): if numpy and isinstance(table, numpy.ndarray):
numpy_table: NumpyArray = table
if copy_table: if copy_table:
table = table.copy() numpy_table = numpy_table.copy()
if table.shape in [ if numpy_table.shape in [
(items * channels,), (items * channels,),
(items, channels), (items, channels),
(size[2], size[1], size[0], channels), (size[2], size[1], size[0], channels),
]: ]:
table = table.reshape(items * channels) table = numpy_table.reshape(items * channels)
else: else:
wrong_size = True wrong_size = True
@ -428,7 +440,8 @@ class Color3DLUT(MultibandFilter):
# Convert to a flat list # Convert to a flat list
if table and isinstance(table[0], (list, tuple)): if table and isinstance(table[0], (list, tuple)):
table, raw_table = [], table raw_table = cast(Sequence[Sequence[int]], table)
flat_table: list[int] = []
for pixel in raw_table: for pixel in raw_table:
if len(pixel) != channels: if len(pixel) != channels:
msg = ( msg = (
@ -436,7 +449,8 @@ class Color3DLUT(MultibandFilter):
f"have a length of {channels}." f"have a length of {channels}."
) )
raise ValueError(msg) raise ValueError(msg)
table.extend(pixel) flat_table.extend(pixel)
table = flat_table
if wrong_size or len(table) != items * channels: if wrong_size or len(table) != items * channels:
msg = ( msg = (
@ -449,7 +463,7 @@ class Color3DLUT(MultibandFilter):
self.table = table self.table = table
@staticmethod @staticmethod
def _check_size(size: Any) -> list[int]: def _check_size(size: Any) -> tuple[int, int, int]:
try: try:
_, _, _ = size _, _, _ = size
except ValueError as e: except ValueError as e:
@ -457,7 +471,7 @@ class Color3DLUT(MultibandFilter):
raise ValueError(msg) from e raise ValueError(msg) from e
except TypeError: except TypeError:
size = (size, size, size) size = (size, size, size)
size = [int(x) for x in size] size = tuple(int(x) for x in size)
for size_1d in size: for size_1d in size:
if not 2 <= size_1d <= 65: if not 2 <= size_1d <= 65:
msg = "Size should be in [2, 65] range." msg = "Size should be in [2, 65] range."
@ -465,7 +479,13 @@ class Color3DLUT(MultibandFilter):
return size return size
@classmethod @classmethod
def generate(cls, size, callback, channels=3, target_mode=None): def generate(
cls,
size: int | tuple[int, int, int],
callback: Callable[[float, float, float], tuple[float, ...]],
channels: int = 3,
target_mode: str | None = None,
) -> Color3DLUT:
"""Generates new LUT using provided callback. """Generates new LUT using provided callback.
:param size: Size of the table. Passed to the constructor. :param size: Size of the table. Passed to the constructor.
@ -482,7 +502,7 @@ class Color3DLUT(MultibandFilter):
msg = "Only 3 or 4 output channels are supported" msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg) raise ValueError(msg)
table = [0] * (size_1d * size_2d * size_3d * channels) table: list[float] = [0] * (size_1d * size_2d * size_3d * channels)
idx_out = 0 idx_out = 0
for b in range(size_3d): for b in range(size_3d):
for g in range(size_2d): for g in range(size_2d):
@ -500,7 +520,13 @@ class Color3DLUT(MultibandFilter):
_copy_table=False, _copy_table=False,
) )
def transform(self, callback, with_normals=False, channels=None, target_mode=None): def transform(
self,
callback: Callable[..., tuple[float, ...]],
with_normals: bool = False,
channels: int | None = None,
target_mode: str | None = None,
) -> Color3DLUT:
"""Transforms the table values using provided callback and returns """Transforms the table values using provided callback and returns
a new LUT with altered values. a new LUT with altered values.
@ -564,7 +590,7 @@ class Color3DLUT(MultibandFilter):
r.append(f"target_mode={self.mode}") r.append(f"target_mode={self.mode}")
return "<{}>".format(" ".join(r)) return "<{}>".format(" ".join(r))
def filter(self, image): def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
from . import Image from . import Image
return image.color_lut_3d( return image.color_lut_3d(

View File

@ -33,11 +33,12 @@ import sys
import warnings import warnings
from enum import IntEnum from enum import IntEnum
from io import BytesIO from io import BytesIO
from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, BinaryIO from typing import IO, TYPE_CHECKING, Any, BinaryIO
from . import Image from . import Image
from ._typing import StrOrBytesPath from ._typing import StrOrBytesPath
from ._util import is_path from ._util import DeferredError, is_path
if TYPE_CHECKING: if TYPE_CHECKING:
from . import ImageFile from . import ImageFile
@ -53,11 +54,10 @@ class Layout(IntEnum):
MAX_STRING_LENGTH = 1_000_000 MAX_STRING_LENGTH = 1_000_000
core: ModuleType | DeferredError
try: try:
from . import _imagingft as core from . import _imagingft as core
except ImportError as ex: except ImportError as ex:
from ._util import DeferredError
core = DeferredError.new(ex) core = DeferredError.new(ex)
@ -199,6 +199,7 @@ class FreeTypeFont:
"""FreeType font wrapper (requires _imagingft service)""" """FreeType font wrapper (requires _imagingft service)"""
font: Font font: Font
font_bytes: bytes
def __init__( def __init__(
self, self,
@ -210,6 +211,9 @@ class FreeTypeFont:
) -> None: ) -> None:
# FIXME: use service provider instead # FIXME: use service provider instead
if isinstance(core, DeferredError):
raise core.ex
if size <= 0: if size <= 0:
msg = "font size must be greater than 0" msg = "font size must be greater than 0"
raise ValueError(msg) raise ValueError(msg)
@ -279,7 +283,7 @@ class FreeTypeFont:
return self.font.ascent, self.font.descent return self.font.ascent, self.font.descent
def getlength( def getlength(
self, text: str, mode="", direction=None, features=None, language=None self, text: str | bytes, mode="", direction=None, features=None, language=None
) -> float: ) -> float:
""" """
Returns length (in pixels with 1/64 precision) of given text when rendered Returns length (in pixels with 1/64 precision) of given text when rendered
@ -354,7 +358,7 @@ class FreeTypeFont:
def getbbox( def getbbox(
self, self,
text: str, text: str | bytes,
mode: str = "", mode: str = "",
direction: str | None = None, direction: str | None = None,
features: list[str] | None = None, features: list[str] | None = None,
@ -511,7 +515,7 @@ class FreeTypeFont:
def getmask2( def getmask2(
self, self,
text: str, text: str | bytes,
mode="", mode="",
direction=None, direction=None,
features=None, features=None,
@ -730,7 +734,7 @@ class TransposedFont:
return 0, 0, height, width return 0, 0, height, width
return 0, 0, width, height return 0, 0, width, height
def getlength(self, text: str, *args, **kwargs) -> float: def getlength(self, text: str | bytes, *args, **kwargs) -> float:
if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270):
msg = "text length is undefined for text rotated by 90 or 270 degrees" msg = "text length is undefined for text rotated by 90 or 270 degrees"
raise ValueError(msg) raise ValueError(msg)
@ -775,10 +779,15 @@ def truetype(
:param font: A filename or file-like object containing a TrueType font. :param font: A filename or file-like object containing a TrueType font.
If the file is not found in this filename, the loader may also If the file is not found in this filename, the loader may also
search in other directories, such as the :file:`fonts/` search in other directories, such as:
directory on Windows or :file:`/Library/Fonts/`,
:file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on * The :file:`fonts/` directory on Windows,
macOS. * :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/`
and :file:`~/Library/Fonts/` on macOS.
* :file:`~/.local/share/fonts`, :file:`/usr/local/share/fonts`,
and :file:`/usr/share/fonts` on Linux; or those specified by
the ``XDG_DATA_HOME`` and ``XDG_DATA_DIRS`` environment variables
for user-installed and system-wide fonts, respectively.
:param size: The requested size, in pixels. :param size: The requested size, in pixels.
:param index: Which font face to load (default is first available face). :param index: Which font face to load (default is first available face).
@ -837,12 +846,21 @@ def truetype(
if windir: if windir:
dirs.append(os.path.join(windir, "fonts")) dirs.append(os.path.join(windir, "fonts"))
elif sys.platform in ("linux", "linux2"): elif sys.platform in ("linux", "linux2"):
lindirs = os.environ.get("XDG_DATA_DIRS") data_home = os.environ.get("XDG_DATA_HOME")
if not lindirs: if not data_home:
# According to the freedesktop spec, XDG_DATA_DIRS should # The freedesktop spec defines the following default directory for
# default to /usr/share # when XDG_DATA_HOME is unset or empty. This user-level directory
lindirs = "/usr/share" # takes precedence over system-level directories.
dirs += [os.path.join(lindir, "fonts") for lindir in lindirs.split(":")] data_home = os.path.expanduser("~/.local/share")
xdg_dirs = [data_home]
data_dirs = os.environ.get("XDG_DATA_DIRS")
if not data_dirs:
# Similarly, defaults are defined for the system-level directories
data_dirs = "/usr/local/share:/usr/share"
xdg_dirs += data_dirs.split(":")
dirs += [os.path.join(xdg_dir, "fonts") for xdg_dir in xdg_dirs]
elif sys.platform == "darwin": elif sys.platform == "darwin":
dirs += [ dirs += [
"/Library/Fonts", "/Library/Fonts",
@ -903,7 +921,7 @@ def load_default(size: float | None = None) -> FreeTypeFont | ImageFont:
:return: A font object. :return: A font object.
""" """
f: FreeTypeFont | ImageFont f: FreeTypeFont | ImageFont
if core.__class__.__name__ == "module" or size is not None: if isinstance(core, ModuleType) or size is not None:
f = truetype( f = truetype(
BytesIO( BytesIO(
base64.b64decode( base64.b64decode(

View File

@ -26,7 +26,13 @@ import tempfile
from . import Image from . import Image
def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None): def grab(
bbox: tuple[int, int, int, int] | None = None,
include_layered_windows: bool = False,
all_screens: bool = False,
xdisplay: str | None = None,
) -> Image.Image:
im: Image.Image
if xdisplay is None: if xdisplay is None:
if sys.platform == "darwin": if sys.platform == "darwin":
fh, filepath = tempfile.mkstemp(".png") fh, filepath = tempfile.mkstemp(".png")
@ -63,14 +69,16 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N
left, top, right, bottom = bbox left, top, right, bottom = bbox
im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) im = im.crop((left - x0, top - y0, right - x0, bottom - y0))
return im return im
# Cast to Optional[str] needed for Windows and macOS.
display_name: str | None = xdisplay
try: try:
if not Image.core.HAVE_XCB: if not Image.core.HAVE_XCB:
msg = "Pillow was built without XCB support" msg = "Pillow was built without XCB support"
raise OSError(msg) raise OSError(msg)
size, data = Image.core.grabscreen_x11(xdisplay) size, data = Image.core.grabscreen_x11(display_name)
except OSError: except OSError:
if ( if (
xdisplay is None display_name is None
and sys.platform not in ("darwin", "win32") and sys.platform not in ("darwin", "win32")
and shutil.which("gnome-screenshot") and shutil.which("gnome-screenshot")
): ):
@ -94,7 +102,7 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N
return im return im
def grabclipboard(): def grabclipboard() -> Image.Image | list[str] | None:
if sys.platform == "darwin": if sys.platform == "darwin":
fh, filepath = tempfile.mkstemp(".png") fh, filepath = tempfile.mkstemp(".png")
os.close(fh) os.close(fh)

View File

@ -717,6 +717,9 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image
exif_image.info["XML:com.adobe.xmp"] = re.sub( exif_image.info["XML:com.adobe.xmp"] = re.sub(
pattern, "", exif_image.info["XML:com.adobe.xmp"] pattern, "", exif_image.info["XML:com.adobe.xmp"]
) )
exif_image.info["xmp"] = re.sub(
pattern.encode(), b"", exif_image.info["xmp"]
)
if not in_place: if not in_place:
return transposed_image return transposed_image
elif not in_place: elif not in_place:

View File

@ -38,23 +38,27 @@ class ImagePalette:
Defaults to an empty palette. Defaults to an empty palette.
""" """
def __init__(self, mode: str = "RGB", palette: Sequence[int] | None = None) -> None: def __init__(
self,
mode: str = "RGB",
palette: Sequence[int] | bytes | bytearray | None = None,
) -> None:
self.mode = mode self.mode = mode
self.rawmode = None # if set, palette contains raw data self.rawmode: str | None = None # if set, palette contains raw data
self.palette = palette or bytearray() self.palette = palette or bytearray()
self.dirty: int | None = None self.dirty: int | None = None
@property @property
def palette(self): def palette(self) -> Sequence[int] | bytes | bytearray:
return self._palette return self._palette
@palette.setter @palette.setter
def palette(self, palette): def palette(self, palette: Sequence[int] | bytes | bytearray) -> None:
self._colors = None self._colors: dict[tuple[int, ...], int] | None = None
self._palette = palette self._palette = palette
@property @property
def colors(self): def colors(self) -> dict[tuple[int, ...], int]:
if self._colors is None: if self._colors is None:
mode_len = len(self.mode) mode_len = len(self.mode)
self._colors = {} self._colors = {}
@ -66,7 +70,7 @@ class ImagePalette:
return self._colors return self._colors
@colors.setter @colors.setter
def colors(self, colors): def colors(self, colors: dict[tuple[int, ...], int]) -> None:
self._colors = colors self._colors = colors
def copy(self) -> ImagePalette: def copy(self) -> ImagePalette:
@ -80,7 +84,7 @@ class ImagePalette:
return new return new
def getdata(self) -> tuple[str, bytes]: def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]:
""" """
Get palette contents in format suitable for the low-level Get palette contents in format suitable for the low-level
``im.putpalette`` primitive. ``im.putpalette`` primitive.
@ -107,11 +111,13 @@ class ImagePalette:
# Declare tostring as an alias for tobytes # Declare tostring as an alias for tobytes
tostring = tobytes tostring = tobytes
def _new_color_index(self, image=None, e=None): def _new_color_index(
self, image: Image.Image | None = None, e: Exception | None = None
) -> int:
if not isinstance(self.palette, bytearray): if not isinstance(self.palette, bytearray):
self._palette = bytearray(self.palette) self._palette = bytearray(self.palette)
index = len(self.palette) // 3 index = len(self.palette) // 3
special_colors = () special_colors: tuple[int | tuple[int, ...] | None, ...] = ()
if image: if image:
special_colors = ( special_colors = (
image.info.get("background"), image.info.get("background"),
@ -133,7 +139,7 @@ class ImagePalette:
def getcolor( def getcolor(
self, self,
color: tuple[int, int, int] | tuple[int, int, int, int], color: tuple[int, ...],
image: Image.Image | None = None, image: Image.Image | None = None,
) -> int: ) -> int:
"""Given an rgb tuple, allocate palette entry. """Given an rgb tuple, allocate palette entry.
@ -158,12 +164,13 @@ class ImagePalette:
except KeyError as e: except KeyError as e:
# allocate new color slot # allocate new color slot
index = self._new_color_index(image, e) index = self._new_color_index(image, e)
assert isinstance(self._palette, bytearray)
self.colors[color] = index self.colors[color] = index
if index * 3 < len(self.palette): if index * 3 < len(self.palette):
self._palette = ( self._palette = (
self.palette[: index * 3] self._palette[: index * 3]
+ bytes(color) + bytes(color)
+ self.palette[index * 3 + 3 :] + self._palette[index * 3 + 3 :]
) )
else: else:
self._palette += bytes(color) self._palette += bytes(color)
@ -200,7 +207,7 @@ class ImagePalette:
# Internal # Internal
def raw(rawmode, data) -> ImagePalette: def raw(rawmode, data: Sequence[int] | bytes | bytearray) -> ImagePalette:
palette = ImagePalette() palette = ImagePalette()
palette.rawmode = rawmode palette.rawmode = rawmode
palette.palette = data palette.palette = data
@ -212,9 +219,9 @@ def raw(rawmode, data) -> ImagePalette:
# Factories # Factories
def make_linear_lut(black, white): def make_linear_lut(black: int, white: float) -> list[int]:
if black == 0: if black == 0:
return [white * i // 255 for i in range(256)] return [int(white * i // 255) for i in range(256)]
msg = "unavailable when black is non-zero" msg = "unavailable when black is non-zero"
raise NotImplementedError(msg) # FIXME raise NotImplementedError(msg) # FIXME
@ -247,15 +254,22 @@ def wedge(mode: str = "RGB") -> ImagePalette:
return ImagePalette(mode, [i // len(mode) for i in palette]) return ImagePalette(mode, [i // len(mode) for i in palette])
def load(filename): def load(filename: str) -> tuple[bytes, str]:
# FIXME: supports GIMP gradients only # FIXME: supports GIMP gradients only
with open(filename, "rb") as fp: with open(filename, "rb") as fp:
for paletteHandler in [ paletteHandlers: list[
type[
GimpPaletteFile.GimpPaletteFile
| GimpGradientFile.GimpGradientFile
| PaletteFile.PaletteFile
]
] = [
GimpPaletteFile.GimpPaletteFile, GimpPaletteFile.GimpPaletteFile,
GimpGradientFile.GimpGradientFile, GimpGradientFile.GimpGradientFile,
PaletteFile.PaletteFile, PaletteFile.PaletteFile,
]: ]
for paletteHandler in paletteHandlers:
try: try:
fp.seek(0) fp.seek(0)
lut = paletteHandler(fp).getpalette() lut = paletteHandler(fp).getpalette()

View File

@ -152,7 +152,7 @@ def _toqclass_helper(im):
elif im.mode == "RGBA": elif im.mode == "RGBA":
data = im.tobytes("raw", "BGRA") data = im.tobytes("raw", "BGRA")
format = qt_format.Format_ARGB32 format = qt_format.Format_ARGB32
elif im.mode == "I;16" and hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ elif im.mode == "I;16":
im = im.point(lambda i: i * 256) im = im.point(lambda i: i * 256)
format = qt_format.Format_Grayscale16 format = qt_format.Format_Grayscale16
@ -196,7 +196,7 @@ if qt_is_installed:
self.setColorTable(im_data["colortable"]) self.setColorTable(im_data["colortable"])
def toqimage(im): def toqimage(im) -> ImageQt:
return ImageQt(im) return ImageQt(im)

View File

@ -24,7 +24,7 @@ class Transform(Image.ImageTransformHandler):
method: Image.Transform method: Image.Transform
def __init__(self, data: Sequence[int]) -> None: def __init__(self, data: Sequence[Any]) -> None:
self.data = data self.data = data
def getdata(self) -> tuple[Image.Transform, Sequence[int]]: def getdata(self) -> tuple[Image.Transform, Sequence[int]]:

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