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
- 7z x nasm-win64.zip -oc:\
- 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\
- ps: |
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 \
libimagequant \
libjpeg \
libraqm \
libtiff \
little-cms2 \
openjpeg \
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"
# TODO Update condition when cffi supports 3.13

View File

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

View File

@ -5,6 +5,51 @@ Changelog (Pillow)
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
[radarhere]

View File

@ -18,7 +18,7 @@ from typing import Any, Callable, Sequence
import pytest
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__)
@ -240,7 +240,7 @@ class PillowLeakTestCase:
# helpers
def fromstring(data: bytes) -> Image.Image:
def fromstring(data: bytes) -> ImageFile.ImageFile:
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:
def test_wrong_args(self) -> None:
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"):
ImageFilter.Color3DLUT((11, 11), [1])
ImageFilter.Color3DLUT((11, 11), [1]) # type: ignore[arg-type]
with pytest.raises(ValueError, match=r"in \[2, 65\] range"):
ImageFilter.Color3DLUT((11, 11, 1), [1])

View File

@ -12,7 +12,7 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS
class TestDecompressionBomb:
def teardown_method(self, method) -> None:
def teardown_method(self) -> None:
Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT
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
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:
for i in range(3):
for _ in range(3):
im.seek(0)
assert im.info["duration"] == 4000
im.seek(1)
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()
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:
self.saved = True
@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None:
BufrStubImagePlugin.register_handler(handler)
with Image.open(TEST_FILE) as im:
assert handler.opened
assert not handler.loaded
assert not handler.is_loaded()
im.load()
assert handler.loaded
assert handler.is_loaded()
temp_file = str(tmp_path / "temp.bufr")
im.save(temp_file)

View File

@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None:
im.fp.close()
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:
self.saved = True
@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None:
GribStubImagePlugin.register_handler(handler)
with Image.open(TEST_FILE) as im:
assert handler.opened
assert not handler.loaded
assert not handler.is_loaded()
im.load()
assert handler.loaded
assert handler.is_loaded()
temp_file = str(tmp_path / "temp.grib")
im.save(temp_file)

View File

@ -66,6 +66,9 @@ def test_handler(tmp_path: Path) -> None:
im.fp.close()
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:
self.saved = True
@ -73,10 +76,10 @@ def test_handler(tmp_path: Path) -> None:
Hdf5StubImagePlugin.register_handler(handler)
with Image.open(TEST_FILE) as im:
assert handler.opened
assert not handler.loaded
assert not handler.is_loaded()
im.load()
assert handler.loaded
assert handler.is_loaded()
temp_file = str(tmp_path / "temp.h5")
im.save(temp_file)

View File

@ -443,7 +443,9 @@ class TestFileJpeg:
assert_image(im1, im2.mode, im2.size)
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
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:
with Image.open(TEST_FILE) as img:
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
assert_image_similar_tofile(img, tempfile, 17)
@ -917,24 +919,25 @@ class TestFileJpeg:
with Image.open("Tests/images/icc-after-SOF.jpg") as im:
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
buffer = BytesIO(b"\xFF" * size) # Many xFF bytes
buffer.max_pos = 0
max_pos = 0
orig_read = buffer.read
def read(n=-1):
def read(n: int | None = -1) -> bytes:
nonlocal max_pos
res = orig_read(n)
buffer.max_pos = max(buffer.max_pos, buffer.tell())
max_pos = max(max_pos, buffer.tell())
return res
buffer.read = read
monkeypatch.setattr(buffer, "read", read)
with pytest.raises(UnidentifiedImageError):
with Image.open(buffer):
pass
# Assert the entire file has not been read
assert 0 < buffer.max_pos < size
assert 0 < max_pos < size
def test_getxmp(self) -> None:
with Image.open("Tests/images/xmp_test.jpg") as im:
@ -945,6 +948,7 @@ class TestFileJpeg:
):
assert im.getxmp() == {}
else:
assert "xmp" in im.info
xmp = im.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"]
@ -1029,8 +1033,10 @@ class TestFileJpeg:
def test_repr_jpeg(self) -> None:
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_image_similar(im, repr_jpeg, 17)

View File

@ -335,9 +335,15 @@ def test_issue_6194() -> None:
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:
# 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"):
pass
@ -460,7 +466,7 @@ def test_plt_marker() -> None:
out.seek(length - 2, os.SEEK_CUR)
def test_9bit():
def test_9bit() -> None:
with Image.open("Tests/images/9bit.j2k") as im:
assert im.mode == "I;16"
assert im.size == (128, 128)

View File

@ -226,6 +226,11 @@ def test_eoferror() -> None:
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)
def test_image_grab(test_file: str) -> None:
with Image.open(test_file) as im:

View File

@ -535,8 +535,10 @@ class TestFilePng:
def test_repr_png(self) -> None:
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_image_equal(im, repr_png)
@ -655,11 +657,12 @@ class TestFilePng:
png.call(cid, 0, 0)
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")
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:
assert len(reloaded.png.im_palette[1]) == 48
@ -683,6 +686,7 @@ class TestFilePng:
):
assert im.getxmp() == {}
else:
assert "xmp" in im.info
xmp = im.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"]
@ -767,16 +771,12 @@ class TestFilePng:
def test_save_stdout(self, buffer: bool) -> None:
old_stdout = sys.stdout
if buffer:
class MyStdOut:
buffer = BytesIO()
class MyStdOut:
buffer = BytesIO()
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
mystdout = MyStdOut()
else:
mystdout = BytesIO()
sys.stdout = mystdout
sys.stdout = mystdout # type: ignore[assignment]
with Image.open(TEST_PNG_FILE) as im:
im.save(sys.stdout, "PNG")
@ -784,7 +784,7 @@ class TestFilePng:
# Reset stdout
sys.stdout = old_stdout
if buffer:
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer
with Image.open(mystdout) as reloaded:
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:
old_stdout = sys.stdout
if buffer:
class MyStdOut:
buffer = BytesIO()
class MyStdOut:
buffer = BytesIO()
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
mystdout = MyStdOut()
else:
mystdout = BytesIO()
sys.stdout = mystdout
sys.stdout = mystdout # type: ignore[assignment]
with Image.open(TEST_FILE) as im:
im.save(sys.stdout, "PPM")
@ -385,7 +381,7 @@ def test_save_stdout(buffer: bool) -> None:
# Reset stdout
sys.stdout = old_stdout
if buffer:
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer
with Image.open(mystdout) as reloaded:
assert_image_equal_tofile(reloaded, TEST_FILE)

View File

@ -4,7 +4,7 @@ import warnings
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
@ -150,20 +150,26 @@ def test_combined_larger_than_size() -> None:
@pytest.mark.parametrize(
"test_file,raises",
[
(
"Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd",
UnidentifiedImageError,
),
(
"Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd",
UnidentifiedImageError,
),
("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.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 pytest.raises(raises):
with Image.open(f):
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)
# Assert
assert img_list is not None
assert len(img_list) == 1
assert isinstance(img_list[0], Image.Image)
assert img_list[0].size == (128, 128)

View File

@ -113,14 +113,14 @@ class TestFileTiff:
outfile = str(tmp_path / "temp.tif")
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"):
Image.open("Tests/images/seek_too_large.tif")
def test_set_legacy_api(self) -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2()
with pytest.raises(Exception) as e:
ifd.legacy_api = None
ifd.legacy_api = False
assert str(e.value) == "Not allowing setting of legacy api"
def test_xyres_tiff(self) -> None:
@ -621,6 +621,19 @@ class TestFileTiff:
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:
outfile = str(tmp_path / "temp.tif")
im = hopper()
@ -810,6 +823,7 @@ class TestFileTiff:
):
assert im.getxmp() == {}
else:
assert "xmp" in im.info
xmp = im.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"]

View File

@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational
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:
@ -411,8 +415,8 @@ def test_empty_values() -> None:
info = TiffImagePlugin.ImageFileDirectory_v2(head)
info.load(data)
# Should not raise ValueError.
info = dict(info)
assert 33432 in info
info_dict = dict(info)
assert 33432 in info_dict
def test_photoshop_info(tmp_path: Path) -> None:

View File

@ -5,6 +5,7 @@ import sys
import warnings
from io import BytesIO
from pathlib import Path
from typing import Any
import pytest
@ -70,7 +71,9 @@ class TestFileWebp:
# dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm
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")
hopper(mode).save(temp_file, **args)

View File

@ -129,6 +129,7 @@ def test_getxmp() -> None:
):
assert im.getxmp() == {}
else:
assert "xmp" in im.info
assert (
im.getxmp()["xmpmeta"]["xmptk"]
== "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:
if features.check_module("freetype2"):
ImageFont.core = _util.DeferredError(ImportError)
ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
try:
default_font = ImageFont.load_default()
finally:

View File

@ -8,7 +8,7 @@ import sys
import tempfile
import warnings
from pathlib import Path
from typing import IO
from typing import IO, Any
import pytest
@ -152,7 +152,7 @@ class TestImage:
def test_stringio(self) -> None:
with pytest.raises(ValueError):
with Image.open(io.StringIO()):
with Image.open(io.StringIO()): # type: ignore[arg-type]
pass
def test_pathlib(self, tmp_path: Path) -> None:
@ -175,11 +175,19 @@ class TestImage:
def test_fp_name(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.jpg")
class FP:
class FP(io.BytesIO):
name: str
def write(self, b: bytes) -> None:
pass
if sys.version_info >= (3, 12):
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.name = temp_file
@ -393,13 +401,13 @@ class TestImage:
# errors
with pytest.raises(ValueError):
source.alpha_composite(over, "invalid source")
source.alpha_composite(over, "invalid destination") # type: ignore[arg-type]
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):
source.alpha_composite(over, 0)
source.alpha_composite(over, 0) # type: ignore[arg-type]
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):
source.alpha_composite(over, (0, 0), (0, -1))
@ -909,6 +917,10 @@ class TestImage:
assert tag not in exif.get_ifd(0x8769)
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)))
def test_zero_tobytes(self, size: tuple[int, int]) -> None:
im = Image.new("RGB", size)

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import pytest
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))
if TYPE_CHECKING:
import numpy.typing as npt
def test_toarray() -> None:
def test(mode: str) -> tuple[tuple[int, ...], str, int]:
ai = numpy.array(im.convert(mode))
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)
assert ai.dtype == dtype

View File

@ -16,7 +16,9 @@ def draft_roundtrip(
im = Image.new(in_mode, in_size)
data = tostring(im, "JPEG")
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
assert box[:2] == (0, 0)
assert (im.width - scale) < box[2] <= im.width

View File

@ -137,7 +137,7 @@ def test_builtinfilter_p() -> None:
builtin_filter = ImageFilter.BuiltinFilter()
with pytest.raises(ValueError):
builtin_filter.filter(hopper("P"))
builtin_filter.filter(hopper("P").im)
def test_kernel_not_enough_coefficients() -> None:

View File

@ -68,7 +68,11 @@ def test_sanity() -> None:
),
)
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:
assert Image.getmodebase(mode) == expected_base
assert Image.getmodetype(mode) == expected_type

View File

@ -338,3 +338,8 @@ class TestImagingPaste:
im.copy().paste(im2)
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:
im = hopper("F")
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)),
("RGBAX", (1, 2, 3, 4, 0)),
("ARGB", (4, 1, 2, 3)),
),
)
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(
"method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE)
)
def test_quantize_kmeans(method) -> None:
def test_quantize_kmeans(method: Image.Quantize) -> None:
im = hopper()
no_kmeans = im.quantize(kmeans=0, 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(
"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))
with pytest.raises(expected_error):
im.reduce(size)
im.reduce(size) # type: ignore[arg-type]
@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),
),
)
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))
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"))

View File

@ -445,7 +445,7 @@ class TestCoreResampleBox:
im.resize((32, 32), resample, (20, 20, 100, 20))
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"):
im.resize((32, 32), resample, (-20, 20, 100, 100))

View File

@ -16,7 +16,7 @@ from .helper import (
def test_sanity() -> None:
im = hopper()
assert im.thumbnail((100, 100)) is None
im.thumbnail((100, 100))
assert im.size == (100, 100)

View File

@ -103,7 +103,7 @@ def test_sanity() -> 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(256) == ImageCms.Flags.NONE
@ -569,9 +569,9 @@ def assert_aux_channel_preserved(
for delta in nine_grid_deltas:
channel_data.paste(
channel_pattern,
tuple(
paste_offset[c] + delta[c] * channel_pattern.size[c]
for c in range(2)
(
paste_offset[0] + delta[0] * channel_pattern.size[0],
paste_offset[1] + delta[1] * channel_pattern.size[1],
),
)
chans.append(channel_data)
@ -642,7 +642,8 @@ def test_auxiliary_channels_isolated() -> None:
# convert with and without AUX data, test colors are equal
src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1])
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]
test_transform = ImageCms.buildTransform(
source_profile,

View File

@ -448,6 +448,7 @@ def test_shape1() -> None:
x3, y3 = 95, 5
# Act
assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3)
@ -469,6 +470,7 @@ def test_shape2() -> None:
x3, y3 = 5, 95
# Act
assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3)
@ -487,6 +489,7 @@ def test_transform() -> None:
draw = ImageDraw.Draw(im)
# Act
assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.line(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")
@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("kite_points", KITE_POINTS)
def test_polygon_kite(
@ -913,7 +929,12 @@ def test_rounded_rectangle_translucent(
def test_floodfill(bbox: Coords) -> None:
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
im = Image.new(mode, (W, H))
draw = ImageDraw.Draw(im)
@ -1429,6 +1450,7 @@ def test_same_color_outline(bbox: Coords) -> None:
x2, y2 = 95, 50
x3, y3 = 95, 5
assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3)
@ -1467,7 +1489,7 @@ def test_same_color_outline(bbox: Coords) -> None:
(4, "square", {}),
(8, "regular_octagon", {}),
(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(
@ -1477,7 +1499,10 @@ def test_draw_regular_polygon(
filename = f"Tests/images/imagedraw_{polygon_name}.png"
draw = ImageDraw.Draw(im)
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)
@ -1562,10 +1587,14 @@ def test_compute_regular_polygon_vertices(
],
)
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:
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
@ -1626,6 +1655,6 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None:
draw.rounded_rectangle(xy)
def test_getdraw():
def test_getdraw() -> None:
with pytest.warns(DeprecationWarning):
ImageDraw.getdraw(None, [])

View File

@ -51,9 +51,18 @@ def test_sanity() -> None:
pen = ImageDraw2.Pen("blue", width=7)
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)
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)

View File

@ -209,7 +209,7 @@ class MockPyDecoder(ImageFile.PyDecoder):
super().__init__(mode, *args)
def decode(self, buffer):
def decode(self, buffer: bytes) -> tuple[int, int]:
# eof
return -1, 0
@ -222,7 +222,7 @@ class MockPyEncoder(ImageFile.PyEncoder):
super().__init__(mode, *args)
def encode(self, buffer):
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
return 1, 1, b""
def cleanup(self) -> None:
@ -351,7 +351,9 @@ class TestPyEncoder(CodecsTest):
ImageFile._save(
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):
ImageFile._save(
@ -381,7 +383,7 @@ class TestPyEncoder(CodecsTest):
def test_encode(self) -> None:
encoder = ImageFile.PyEncoder(None)
with pytest.raises(NotImplementedError):
encoder.encode(None)
encoder.encode(0)
bytes_consumed, errcode = encoder.encode_to_pyfd()
assert bytes_consumed == 0

View File

@ -209,7 +209,7 @@ def test_getlength(
assert length == length_raqm
def test_float_size() -> None:
def test_float_size(layout_engine: ImageFont.Layout) -> None:
lengths = []
for size in (48, 48.5, 49):
f = ImageFont.truetype(
@ -224,7 +224,7 @@ def test_render_multiline(font: ImageFont.FreeTypeFont) -> None:
draw = ImageDraw.Draw(im)
line_spacing = font.getbbox("A")[3] + 4
lines = TEST_TEXT.split("\n")
y = 0
y: float = 0
for line in lines:
draw.text((0, y), line, font=font)
y += line_spacing
@ -494,8 +494,8 @@ def test_default_font() -> None:
assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png")
@pytest.mark.parametrize("mode", (None, "1", "RGBA"))
def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None:
@pytest.mark.parametrize("mode", ("", "1", "RGBA"))
def test_getbbox(font: ImageFont.FreeTypeFont, mode: str) -> None:
assert (0, 4, 12, 16) == font.getbbox("A", mode)
@ -548,7 +548,7 @@ def test_find_font(
def loadable_font(
filepath: str, size: int, index: int, encoding: str, *args: Any
):
) -> ImageFont.FreeTypeFont:
_freeTypeFont = getattr(ImageFont, "_FreeTypeFont")
if filepath == path_to_fake:
return _freeTypeFont(FONT_PATH, size, index, encoding, *args)
@ -564,6 +564,7 @@ def test_find_font(
# catching syntax like errors
monkeypatch.setattr(sys, "platform", platform)
if platform == "linux":
monkeypatch.setenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/")
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)
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(
"test_file",
[

View File

@ -14,7 +14,7 @@ original_core = ImageFont.core
def setup_module() -> None:
if features.check_module("freetype2"):
ImageFont.core = _util.DeferredError(ImportError)
ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
def teardown_module() -> None:
@ -76,3 +76,8 @@ def test_oom() -> None:
font = ImageFont.ImageFont()
font._load_pilfont_data(fp, Image.new("L", (1, 1)))
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()
im = ImageGrab.grabclipboard()
assert isinstance(im, list)
assert len(im) == 1
assert os.path.samefile(im[0], "Tests/images/hopper.gif")
@ -105,6 +106,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes)
p.communicate()
im = ImageGrab.grabclipboard()
assert isinstance(im, Image.Image)
assert_image_equal_tofile(im, "Tests/images/hopper.png")
@pytest.mark.skipif(
@ -120,6 +122,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes)
with open(image_path, "rb") as fp:
subprocess.call(["wl-copy"], stdin=fp)
im = ImageGrab.grabclipboard()
assert isinstance(im, Image.Image)
assert_image_equal_tofile(im, image_path)
@pytest.mark.skipif(

View File

@ -454,7 +454,7 @@ def test_autocontrast_cutoff() -> None:
# Test the cutoff argument of autocontrast
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()
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
# Fuzzy match.
def gp(x, y):
def gp(x: int, y: int) -> tuple[int, ...]:
return i.im.getpixel((x, y))
assert 236 <= gp(7, 4)[0] <= 239

View File

@ -45,7 +45,7 @@ def test_getcolor() -> None:
# Test unknown color specifier
with pytest.raises(ValueError):
palette.getcolor("unknown")
palette.getcolor("unknown") # type: ignore[arg-type]
def test_getcolor_rgba_color_rgb_palette() -> None:
@ -88,13 +88,13 @@ def test_file(tmp_path: Path) -> None:
palette.save(f)
p = ImagePalette.load(f)
lut = ImagePalette.load(f)
# load returns raw palette information
assert len(p[0]) == 768
assert p[1] == "RGB"
assert len(lut[0]) == 768
assert lut[1] == "RGB"
p = ImagePalette.raw(p[1], p[0])
p = ImagePalette.raw(lut[1], lut[0])
assert isinstance(p, ImagePalette.ImagePalette)
assert p.palette == palette.tobytes()

View File

@ -41,18 +41,13 @@ def test_rgb() -> None:
checkrgb(0, 0, 255)
def test_image() -> None:
modes = ["1", "RGB", "RGBA", "L", "P"]
qt_format = ImageQt.QImage.Format if ImageQt.qt_version == "6" else ImageQt.QImage
if hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+
modes.append("I;16")
for mode in modes:
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)
@pytest.mark.parametrize("mode", ("1", "RGB", "RGBA", "L", "P", "I;16"))
def test_image(mode: str) -> None:
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:

View File

@ -45,21 +45,22 @@ if is_win32():
memcpy = ctypes.cdll.msvcrt.memcpy
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.restype = ctypes.wintypes.HDC
DeleteDC = ctypes.windll.gdi32.DeleteDC
DeleteDC = windll.gdi32.DeleteDC
DeleteDC.argtypes = [ctypes.wintypes.HDC]
SelectObject = ctypes.windll.gdi32.SelectObject
SelectObject = windll.gdi32.SelectObject
SelectObject.argtypes = [ctypes.wintypes.HDC, ctypes.wintypes.HGDIOBJ]
SelectObject.restype = ctypes.wintypes.HGDIOBJ
DeleteObject = ctypes.windll.gdi32.DeleteObject
DeleteObject = windll.gdi32.DeleteObject
DeleteObject.argtypes = [ctypes.wintypes.HGDIOBJ]
CreateDIBSection = ctypes.windll.gdi32.CreateDIBSection
CreateDIBSection = windll.gdi32.CreateDIBSection
CreateDIBSection.argtypes = [
ctypes.wintypes.HDC,
ctypes.c_void_p,
@ -70,7 +71,7 @@ if is_win32():
]
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.bfType = 0x4D42
bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize

View File

@ -11,7 +11,7 @@ import pytest
"args, report",
((["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
out = subprocess.check_output(args).decode("utf-8")
lines = out.splitlines()

View File

@ -1,20 +1,25 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
import pytest
from PIL import Image
from PIL import Image, _typing
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)
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 boolean:
data = [0, 255] * 50
@ -99,14 +104,14 @@ def test_1d_array() -> None:
assert_image(Image.fromarray(a), "L", (1, 5))
def _test_img_equals_nparray(img: Image.Image, np) -> None:
assert len(np.shape) >= 2
np_size = np.shape[1], np.shape[0]
def _test_img_equals_nparray(img: Image.Image, np_img: _typing.NumpyArray) -> None:
assert len(np_img.shape) >= 2
np_size = np_img.shape[1], np_img.shape[0]
assert img.size == np_size
px = img.load()
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)):
assert_deep_equal(px[x, y], np[y, x])
assert_deep_equal(px[x, y], np_img[y, x])
def test_16bit() -> None:
@ -157,7 +162,7 @@ def test_save_tiff_uint16() -> None:
("HSV", numpy.uint8),
),
)
def test_to_array(mode: str, dtype) -> None:
def test_to_array(mode: str, dtype: npt.DTypeLike) -> None:
img = hopper(mode)
# Resize to non-square
@ -207,7 +212,7 @@ def test_putdata() -> None:
numpy.float64,
),
)
def test_roundtrip_eye(dtype) -> None:
def test_roundtrip_eye(dtype: npt.DTypeLike) -> None:
arr = numpy.eye(10, dtype=dtype)
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
old_stdout = sys.stdout
if buffer:
class MyStdOut:
buffer = BytesIO()
class MyStdOut:
buffer = BytesIO()
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
mystdout = MyStdOut()
else:
mystdout = BytesIO()
sys.stdout = mystdout
sys.stdout = mystdout # type: ignore[assignment]
ps = PSDraw.PSDraw()
_create_document(ps)
@ -71,6 +67,6 @@ def test_stdout(buffer: bool) -> None:
# Reset stdout
sys.stdout = old_stdout
if buffer:
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer
assert mystdout.getvalue() != b""

View File

@ -1,8 +1,9 @@
from __future__ import annotations
import shutil
from io import BytesIO
from pathlib import Path
from typing import Callable
from typing import IO, Callable
import pytest
@ -22,11 +23,11 @@ class TestShellInjection:
self,
tmp_path: Path,
src_img: Image.Image,
save_func: Callable[[Image.Image, int, str], None],
save_func: Callable[[Image.Image, IO[bytes], str | bytes], None],
) -> None:
for filename in test_filenames:
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
with Image.open(dest_file) as im:
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.
.. 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.
: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)
if y != 0:
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()

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
.. image:: ../example/anchors.png
.. image:: ../example/anchors.webp
:alt: Text anchor examples
:align: center

View File

@ -278,26 +278,26 @@ choose to resize relative to a given size.
from PIL import Image, ImageOps
size = (100, 150)
with Image.open("Tests/images/hopper.png") as im:
ImageOps.contain(im, size).save("imageops_contain.png")
ImageOps.cover(im, size).save("imageops_cover.png")
ImageOps.fit(im, size).save("imageops_fit.png")
ImageOps.pad(im, size, color="#f00").save("imageops_pad.png")
with Image.open("Tests/images/hopper.webp") as im:
ImageOps.contain(im, size).save("imageops_contain.webp")
ImageOps.cover(im, size).save("imageops_cover.webp")
ImageOps.fit(im, size).save("imageops_fit.webp")
ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp")
# thumbnail() can also be used,
# but will modify the image object in place
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` |
+================+===========================================+============================================+==========================================+========================================+========================================+
|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 size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+
+----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
| | :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)`` |
+----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
|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`` |
+----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
.. _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.getpixel
.. automethod:: PIL.Image.Image.getprojection
.. automethod:: PIL.Image.Image.getxmp
.. automethod:: PIL.Image.Image.histogram
.. automethod:: PIL.Image.Image.paste
.. automethod:: PIL.Image.Image.point

View File

@ -691,23 +691,7 @@ Methods
:param hints: An optional list of hints.
:returns: A (drawing context, drawing resource factory) tuple.
.. py:method:: floodfill(image, xy, value, border=None, thresh=0)
.. 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.
.. autofunction:: PIL.ImageDraw.floodfill
.. _BCP 47 language code: https://www.w3.org/International/articles/language-tags/
.. _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
size = (100, 150)
with Image.open("Tests/images/hopper.png") as im:
ImageOps.contain(im, size).save("imageops_contain.png")
ImageOps.cover(im, size).save("imageops_cover.png")
ImageOps.fit(im, size).save("imageops_fit.png")
ImageOps.pad(im, size, color="#f00").save("imageops_pad.png")
with Image.open("Tests/images/hopper.webp") as im:
ImageOps.contain(im, size).save("imageops_contain.webp")
ImageOps.cover(im, size).save("imageops_cover.webp")
ImageOps.fit(im, size).save("imageops_fit.webp")
ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp")
# thumbnail() can also be used,
# but will modify the image object in place
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` |
+================+===========================================+============================================+==========================================+========================================+========================================+
|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 size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` |
+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+
+----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
| | :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)`` |
+----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
|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`` |
+----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+
.. autofunction:: contain
.. autofunction:: cover

View File

@ -44,42 +44,23 @@ Access using negative indexes is also possible. ::
-----------------------------
.. 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
numerical value for single band images, and a tuple for
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
Returns the pixel at x,y. The pixel is returned as a single
value for single band images or a tuple for multi-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.
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
numerical value for single band images, and a tuple for
multi-band images. In addition to this, RGB and RGBA tuples
are accepted for P and PA 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:: 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.
:param color: The pixel value according to its mode,
e.g. tuple (r, g, b) for RGB mode.

View File

@ -44,3 +44,4 @@ Access using negative indexes is also possible. ::
.. autoclass:: PIL.PyAccess.PyAccess()
: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
on some Python versions.
.. py:class:: NumpyArray
Typing alias.
.. py:class:: StrOrBytesPath
Typing alias.

View File

@ -18,9 +18,9 @@ is not secure.
- :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.
- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It
will now use ``defusedxml`` instead. If the dependency is not present, an empty
dictionary will be returned and a warning raised.
- ``getxmp()`` was added to :py:class:`~PIL.JpegImagePlugin.JpegImageFile` in Pillow
8.2.0. It will now use ``defusedxml`` instead. If the dependency is not present, an
empty dictionary will be returned and a warning raised.
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.
.. 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.
:align: center

View File

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

View File

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

View File

@ -430,6 +430,7 @@ class BLPEncoder(ImageFile.PyEncoder):
def _write_palette(self) -> bytes:
data = b""
assert self.im is not None
palette = self.im.getpalette("RGBA", "RGBA")
for i in range(len(palette) // 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)
data = struct.pack("<16I", offset, *((0,) * 15))
assert self.im is not None
w, h = self.im.size
data += struct.pack("<16I", w * h, *((0,) * 15))

View File

@ -380,6 +380,7 @@ class DdsImageFile(ImageFile.ImageFile):
elif pfflags & DDPF.PALETTEINDEXED8:
self._mode = "P"
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
self.palette.mode = "RGBA"
elif pfflags & DDPF.FOURCC:
offset = header_size + 4
if fourcc == D3DFMT.DXT1:

View File

@ -230,6 +230,11 @@ class EpsImageFile(ImageFile.ImageFile):
trailer_reached = False
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:
msg = 'EPS header missing "%!PS-Adobe" comment'
raise SyntaxError(msg)
@ -270,6 +275,8 @@ class EpsImageFile(ImageFile.ImageFile):
if byte == b"":
# if we didn't read a byte we must be at the end of the file
if bytes_read == 0:
if reading_header_comments:
check_required_header_comments()
break
elif byte in b"\r\n":
# if we read a line ending character, ignore it and parse what
@ -365,8 +372,6 @@ class EpsImageFile(ImageFile.ImageFile):
trailer_reached = True
bytes_read = 0
check_required_header_comments()
if not self._size:
msg = "cannot determine EPS bounding box"
raise OSError(msg)

View File

@ -115,7 +115,11 @@ class FitsImageFile(ImageFile.ImageFile):
elif number_of_bits in (-32, -64):
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

View File

@ -432,7 +432,7 @@ class GifImageFile(ImageFile.ImageFile):
self._prev_im = self.im
if self._frame_palette:
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:
self.im = None
self._mode = temp_mode
@ -457,6 +457,8 @@ class GifImageFile(ImageFile.ImageFile):
frame_im = self.im.convert("RGBA")
else:
frame_im = self.im.convert("RGB")
assert self.dispose_extent is not None
frame_im = self._crop(frame_im, self.dispose_extent)
self.im = self._prev_im

View File

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

View File

@ -41,7 +41,16 @@ import warnings
from collections.abc import Callable, MutableMapping
from enum import IntEnum
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.
# PILLOW_VERSION was removed in Pillow 9.0.0.
@ -218,7 +227,7 @@ if hasattr(core, "DEFAULT_STRATEGY"):
# Registries
if TYPE_CHECKING:
from . import ImageFile
from . import ImageFile, PyAccess
ID: list[str] = []
OPEN: dict[
str,
@ -410,7 +419,9 @@ def init() -> bool:
# 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
if args is None:
args = ()
@ -433,7 +444,9 @@ def _getdecoder(mode, decoder_name, 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
if args is None:
args = ()
@ -550,10 +563,10 @@ class Image:
return self._size
@property
def mode(self):
def mode(self) -> str:
return self._mode
def _new(self, im) -> Image:
def _new(self, im: core.ImagingCore) -> Image:
new = Image()
new.im = im
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.
:param image_format: Image format.
@ -700,14 +713,14 @@ class Image:
return None
return b.getvalue()
def _repr_png_(self):
def _repr_png_(self) -> bytes | None:
"""iPython display hook support for PNG format.
:returns: PNG version of the image as bytes
"""
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.
:returns: JPEG version of the image as bytes
@ -754,7 +767,7 @@ class Image:
self.putpalette(palette)
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.
@ -776,12 +789,13 @@ class Image:
:returns: A :py:class:`bytes` object.
"""
# may pass tuple instead of argument list
if len(args) == 1 and isinstance(args[0], tuple):
args = args[0]
encoder_args: Any = args
if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple):
# may pass tuple instead of argument list
encoder_args = encoder_args[0]
if encoder_name == "raw" and args == ():
args = self.mode
if encoder_name == "raw" and encoder_args == ():
encoder_args = self.mode
self.load()
@ -789,7 +803,7 @@ class Image:
return b""
# unpack data
e = _getencoder(self.mode, encoder_name, args)
e = _getencoder(self.mode, encoder_name, encoder_args)
e.setimage(self.im)
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.
@ -843,16 +859,17 @@ class Image:
if self.width == 0 or self.height == 0:
return
# may pass tuple instead of argument list
if len(args) == 1 and isinstance(args[0], tuple):
args = args[0]
decoder_args: Any = args
if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple):
# may pass tuple instead of argument list
decoder_args = decoder_args[0]
# default format
if decoder_name == "raw" and args == ():
args = self.mode
if decoder_name == "raw" and decoder_args == ():
decoder_args = self.mode
# unpack data
d = _getdecoder(self.mode, decoder_name, args)
d = _getdecoder(self.mode, decoder_name, decoder_args)
d.setimage(self.im)
s = d.decode(data)
@ -863,7 +880,7 @@ class Image:
msg = "cannot decode image data"
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
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.
: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:
# realize palette
mode, arr = self.palette.getdata()
self.im.putpalette(mode, arr)
self.im.putpalette(self.palette.mode, mode, arr)
self.palette.dirty = 0
self.palette.rawmode = None
if "transparency" in self.info and mode in ("LA", "PA"):
@ -891,9 +908,9 @@ class Image:
self.im.putpalettealphas(self.info["transparency"])
self.palette.mode = "RGBA"
else:
palette_mode = "RGBA" if mode.startswith("RGBA") else "RGB"
self.palette.mode = palette_mode
self.palette.palette = self.im.getpalette(palette_mode, palette_mode)
self.palette.palette = self.im.getpalette(
self.palette.mode, self.palette.mode
)
if self.im is not None:
if cffi and USE_CFFI_ACCESS:
@ -905,6 +922,7 @@ class Image:
if self.pyaccess:
return self.pyaccess
return self.im.pixel_access(self.readonly)
return None
def verify(self) -> None:
"""
@ -996,9 +1014,11 @@ class Image:
if has_transparency and self.im.bands == 3:
transparency = new_im.info["transparency"]
def convert_transparency(m, v):
v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5
return max(0, min(255, int(v)))
def convert_transparency(
m: tuple[float, ...], v: tuple[int, int, int]
) -> 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":
transparency = convert_transparency(matrix, transparency)
@ -1092,7 +1112,10 @@ class Image:
del new_im.info["transparency"]
if trns is not None:
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:
# if we can't make a transparent color, don't leave the old
# transparency hanging around to mess us up.
@ -1142,7 +1165,10 @@ class Image:
if trns is not None:
if new_im.mode == "P":
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:
del new_im.info["transparency"]
if str(e) != "cannot allocate more than 256 colors":
@ -1158,7 +1184,7 @@ class Image:
def quantize(
self,
colors: int = 256,
method: Quantize | None = None,
method: int | None = None,
kmeans: int = 0,
palette=None,
dither: Dither = Dither.FLOYDSTEINBERG,
@ -1250,7 +1276,7 @@ class Image:
__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
4-tuple defining the left, upper, right, and lower pixel
@ -1276,7 +1302,9 @@ class Image:
self.load()
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.
@ -1297,7 +1325,7 @@ class Image:
return im.crop((x0, y0, x1, y1))
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:
"""
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 self.im.getextrema()
def _getxmp(self, xmp_tags):
def get_name(tag):
def getxmp(self):
"""
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)
def get_value(element):
@ -1474,9 +1509,10 @@ class Image:
if ElementTree is None:
warnings.warn("XMP data cannot be read without defusedxml dependency")
return {}
else:
root = ElementTree.fromstring(xmp_tags)
return {get_name(root.tag): get_value(root)}
if "xmp" not in self.info:
return {}
root = ElementTree.fromstring(self.info["xmp"])
return {get_name(root.tag): get_value(root)}
def getexif(self) -> Exif:
"""
@ -1549,7 +1585,11 @@ class Image:
fp = io.BytesIO(data)
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._seek(0)
im.load()
@ -1633,7 +1673,9 @@ class Image:
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.
@ -1720,7 +1762,7 @@ class Image:
def paste(
self,
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,
) -> None:
"""
@ -1762,10 +1804,14 @@ class 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
mask = box
box = None
assert not isinstance(box, Image)
if box is None:
box = (0, 0)
@ -1803,7 +1849,9 @@ class Image:
else:
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
onto this image.
@ -1818,32 +1866,35 @@ class Image:
"""
if not isinstance(source, (list, tuple)):
msg = "Source must be a tuple"
msg = "Source must be a list or tuple"
raise ValueError(msg)
if not isinstance(dest, (list, tuple)):
msg = "Destination must be a tuple"
msg = "Destination must be a list or tuple"
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)
if not len(dest) == 2:
msg = "Destination must be a 2-tuple"
msg = "Destination must be a sequence of length 2"
raise ValueError(msg)
if min(source) < 0:
msg = "Source must be non-negative"
raise ValueError(msg)
if len(source) == 2:
source = source + im.size
# over image, crop if it's not the whole thing.
if source == (0, 0) + im.size:
# over image, crop if it's not the whole image.
if overlay_crop_box == (0, 0) + im.size:
overlay = im
else:
overlay = im.crop(source)
overlay = im.crop(overlay_crop_box)
# 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.
if box == (0, 0) + self.size:
@ -1854,7 +1905,11 @@ class Image:
result = alpha_composite(background, overlay)
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.
@ -1891,7 +1946,9 @@ class Image:
scale, offset = _getscaleoffset(lut)
return self._new(self.im.point_transform(scale, offset))
# 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":
# FIXME: _imaging returns a confusing error message for this case
@ -1899,18 +1956,17 @@ class Image:
raise ValueError(msg)
if mode != "F":
lut = [round(i) for i in lut]
return self._new(self.im.point(lut, mode))
flatLut = [round(i) for i in flatLut]
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
does not have an alpha layer, it's converted to "LA" or "RGBA".
The new layer must be either "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
other color value.
image having the same size as this image, or an integer.
"""
self._ensure_mutable()
@ -1949,6 +2005,7 @@ class Image:
alpha = alpha.convert("L")
else:
# constant alpha
alpha = cast(int, alpha) # see python/typing#1013
try:
self.im.fillband(band, alpha)
except (AttributeError, ValueError):
@ -1960,7 +2017,10 @@ class Image:
self.im.putband(alpha.im, band)
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:
"""
Copies pixel data from a flattened sequence object into the image. The
@ -2011,10 +2071,12 @@ class Image:
palette = ImagePalette.raw(rawmode, data)
self._mode = "PA" if "A" in self.mode else "P"
self.palette = palette
self.palette.mode = "RGB"
self.palette.mode = "RGBA" if "A" in rawmode else "RGB"
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
a single numerical value for single-band images, and a tuple for
@ -2052,9 +2114,8 @@ class Image:
if self.mode == "PA":
alpha = value[3] if len(value) == 4 else 255
value = value[:3]
value = self.palette.getcolor(value, self)
if self.mode == "PA":
value = (value, alpha)
palette_index = self.palette.getcolor(value, self)
value = (palette_index, alpha) if self.mode == "PA" else palette_index
return self.im.putpixel(xy, value)
def remap_palette(self, dest_map, source_palette=None):
@ -2126,7 +2187,7 @@ class Image:
# m_im.putpalette(mapping_palette, 'L') # converts to 'P'
# or just force it.
# 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")
@ -2307,7 +2368,7 @@ class Image:
angle: float,
resample: Resampling = Resampling.NEAREST,
expand: int | bool = False,
center: tuple[int, int] | None = None,
center: tuple[float, float] | None = None,
translate: tuple[int, int] | None = None,
fillcolor: float | tuple[float, ...] | str | None = None,
) -> Image:
@ -2375,10 +2436,7 @@ class Image:
else:
post_trans = translate
if center is None:
# FIXME These should be rounded to ints?
rotn_center = (w / 2.0, h / 2.0)
else:
rotn_center = center
center = (w / 2, h / 2)
angle = -math.radians(angle)
matrix = [
@ -2395,10 +2453,10 @@ class Image:
return a * x + b * y + c, d * x + e * y + f
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[5] += rotn_center[1]
matrix[2] += center[0]
matrix[5] += center[1]
if expand:
# calculate output size
@ -2638,7 +2696,7 @@ class Image:
self,
size: tuple[float, float],
resample: Resampling = Resampling.BICUBIC,
reducing_gap: float = 2.0,
reducing_gap: float | None = 2.0,
) -> None:
"""
Make this image into a thumbnail. This method modifies the
@ -2699,11 +2757,12 @@ class Image:
return x, y
box = None
final_size: tuple[int, int]
if reducing_gap is not None:
preserved_size = preserve_aspect_ratio()
if preserved_size is None:
return
size = preserved_size
final_size = preserved_size
res = self.draft(
None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap))
@ -2717,13 +2776,13 @@ class Image:
preserved_size = preserve_aspect_ratio()
if preserved_size is None:
return
size = preserved_size
final_size = preserved_size
if self.size != size:
im = self.resize(size, resample, box=box, reducing_gap=reducing_gap)
if self.size != final_size:
im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap)
self.im = im.im
self._size = size
self._size = final_size
self._mode = self.im.mode
self.readonly = 0
@ -2990,29 +3049,29 @@ def _wedge() -> Image:
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
: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)):
msg = "Size must be a tuple"
msg = "Size must be a list or tuple"
raise ValueError(msg)
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)
if size[0] < 0 or size[1] < 0:
msg = "Width and height must be >= 0"
raise ValueError(msg)
return True
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:
"""
Creates a new image with the given mode and size.
@ -3061,7 +3120,13 @@ def new(
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.
@ -3089,18 +3154,21 @@ def frombytes(mode, size, data, decoder_name: str = "raw", *args) -> Image:
im = new(mode, size)
if im.width != 0 and im.height != 0:
# may pass tuple instead of argument list
if len(args) == 1 and isinstance(args[0], tuple):
args = args[0]
decoder_args: Any = args
if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple):
# may pass tuple instead of argument list
decoder_args = decoder_args[0]
if decoder_name == "raw" and args == ():
args = mode
if decoder_name == "raw" and decoder_args == ():
decoder_args = mode
im.frombytes(data, decoder_name, args)
im.frombytes(data, decoder_name, decoder_args)
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.
@ -3557,7 +3625,7 @@ def merge(mode: str, bands: Sequence[Image]) -> Image:
def register_open(
id,
id: str,
factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile],
accept: Callable[[bytes], bool | str] | None = None,
) -> None:
@ -3691,7 +3759,7 @@ def _show(image: Image, **options: Any) -> None:
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:
"""
Generate a Mandelbrot set covering the given extent.
@ -3738,19 +3806,18 @@ def radial_gradient(mode: str) -> Image:
# Resources
def _apply_env_variables(env=None) -> None:
if env is None:
env = os.environ
def _apply_env_variables(env: dict[str, str] | None = None) -> None:
env_dict = env if env is not None else os.environ
for var_name, setter in [
("PILLOW_ALIGNMENT", core.set_alignment),
("PILLOW_BLOCK_SIZE", core.set_block_size),
("PILLOW_BLOCKS_MAX", core.set_blocks_max),
]:
if var_name not in env:
if var_name not in env_dict:
continue
var = env[var_name].lower()
var = env_dict[var_name].lower()
units = 1
for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]:
@ -3759,13 +3826,13 @@ def _apply_env_variables(env=None) -> None:
var = var[: -len(postfix)]
try:
var = int(var) * units
var_int = int(var) * units
except ValueError:
warnings.warn(f"{var_name} is not int")
continue
try:
setter(var)
setter(var_int)
except ValueError as e:
warnings.warn(f"{var_name}: {e}")

View File

@ -34,12 +34,25 @@ from __future__ import annotations
import math
import numbers
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 ._deprecate import deprecate
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.
<p>
@ -49,7 +62,9 @@ directly.
class ImageDraw:
font = None
font: (
ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | 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.fill = False
if TYPE_CHECKING:
from . import ImageFont
def getfont(
self,
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
@ -133,34 +145,47 @@ class ImageDraw:
else:
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 self.fill:
fill = self.ink
result_fill = self.ink
else:
ink = self.ink
result_ink = self.ink
else:
if ink is not None:
if isinstance(ink, str):
ink = ImageColor.getcolor(ink, self.mode)
if self.palette and not isinstance(ink, numbers.Number):
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 isinstance(fill, str):
fill = ImageColor.getcolor(fill, self.mode)
if self.palette and not isinstance(fill, numbers.Number):
fill = self.palette.getcolor(fill, self._image)
fill = self.draw.draw_ink(fill)
return ink, fill
result_fill = self.draw.draw_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."""
ink, fill = self._getink(fill)
if ink is not None:
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."""
bitmap.load()
ink, fill = self._getink(fill)
@ -169,30 +194,55 @@ class ImageDraw:
if ink is not None:
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."""
ink, fill = self._getink(outline, fill)
if fill is not None:
self.draw.draw_chord(xy, start, end, fill, 1)
if ink is not None and ink != fill and width != 0:
ink, fill_ink = self._getink(outline, fill)
if fill_ink is not None:
self.draw.draw_chord(xy, start, end, fill_ink, 1)
if ink is not None and ink != fill_ink and width != 0:
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."""
ink, fill = self._getink(outline, fill)
if fill is not None:
self.draw.draw_ellipse(xy, fill, 1)
if ink is not None and ink != fill and width != 0:
ink, fill_ink = self._getink(outline, fill)
if fill_ink is not None:
self.draw.draw_ellipse(xy, fill_ink, 1)
if ink is not None and ink != fill_ink and width != 0:
self.draw.draw_ellipse(xy, ink, 0, width)
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:
"""Draw a circle given center coordinates and a radius."""
ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
self.ellipse(ellipse_xy, fill, outline, width)
def line(self, xy: Coords, fill=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."""
ink = self._getink(fill)[0]
if ink is not None:
@ -222,7 +272,7 @@ class ImageDraw:
def coord_at_angle(
coord: Sequence[float], angle: float
) -> tuple[float, float]:
) -> tuple[float, ...]:
x, y = coord
angle -= 90
distance = width / 2 - 1
@ -263,37 +313,54 @@ class ImageDraw:
]
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."""
shape.close()
ink, fill = self._getink(outline, fill)
if fill is not None:
self.draw.draw_outline(shape, fill, 1)
if ink is not None and ink != fill:
ink, fill_ink = self._getink(outline, fill)
if fill_ink is not None:
self.draw.draw_outline(shape, fill_ink, 1)
if ink is not None and ink != fill_ink:
self.draw.draw_outline(shape, ink, 0)
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:
"""Draw a pieslice."""
ink, fill = self._getink(outline, fill)
if fill is not None:
self.draw.draw_pieslice(xy, start, end, fill, 1)
if ink is not None and ink != fill and width != 0:
ink, fill_ink = self._getink(outline, fill)
if fill_ink is not None:
self.draw.draw_pieslice(xy, start, end, fill_ink, 1)
if ink is not None and ink != fill_ink and width != 0:
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."""
ink, fill = self._getink(fill)
if ink is not None:
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."""
ink, fill = self._getink(outline, fill)
if fill is not None:
self.draw.draw_polygon(xy, fill, 1)
if ink is not None and ink != fill and width != 0:
ink, fill_ink = self._getink(outline, fill)
if fill_ink is not None:
self.draw.draw_polygon(xy, fill_ink, 1)
if ink is not None and ink != fill_ink and width != 0:
if width == 1:
self.draw.draw_polygon(xy, ink, 0, width)
elif self.im is not None:
@ -319,22 +386,41 @@ class ImageDraw:
self.im.paste(im.im, (0, 0) + im.size, mask.im)
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:
"""Draw a regular polygon."""
xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation)
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."""
ink, fill = self._getink(outline, fill)
if fill is not None:
self.draw.draw_rectangle(xy, fill, 1)
if ink is not None and ink != fill and width != 0:
ink, fill_ink = self._getink(outline, fill)
if fill_ink is not None:
self.draw.draw_rectangle(xy, fill_ink, 1)
if ink is not None and ink != fill_ink and width != 0:
self.draw.draw_rectangle(xy, ink, 0, width)
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:
"""Draw a rounded rectangle."""
if isinstance(xy[0], (list, tuple)):
@ -376,10 +462,10 @@ class ImageDraw:
# that is a rectangle
return self.rectangle(xy, fill, outline, width)
r = d // 2
ink, fill = self._getink(outline, fill)
r = int(d // 2)
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], ...]
if full_x:
# Draw top and bottom halves
@ -409,32 +495,32 @@ class ImageDraw:
)
for part in parts:
if pieslice:
self.draw.draw_pieslice(*(part + (fill, 1)))
self.draw.draw_pieslice(*(part + (fill_ink, 1)))
else:
self.draw.draw_arc(*(part + (ink, width)))
if fill is not None:
if fill_ink is not None:
draw_corners(True)
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:
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:
left = [x0, y0, x0 + r, y1]
if corners[0]:
left[1] += r + 1
if corners[3]:
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]
if corners[1]:
right[1] += r + 1
if corners[2]:
right[3] -= r + 1
self.draw.draw_rectangle(right, fill, 1)
if ink is not None and ink != fill and width != 0:
self.draw.draw_rectangle(right, fill_ink, 1)
if ink is not None and ink != fill_ink and width != 0:
draw_corners(False)
if not full_x:
@ -529,10 +615,11 @@ class ImageDraw:
embedded_color,
)
def getink(fill):
ink, fill = self._getink(fill)
def getink(fill: _Ink | None) -> int:
ink, fill_ink = self._getink(fill)
if ink is None:
return fill
assert fill_ink is not None
return fill_ink
return ink
def draw_text(ink, stroke_width=0, stroke_offset=None) -> None:
@ -879,7 +966,7 @@ class ImageDraw:
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.
@ -891,19 +978,14 @@ def Draw(im, mode: str | None = None) -> ImageDraw:
defaults to the mode of the image.
"""
try:
return im.getdraw(mode)
return getattr(im, "getdraw")(mode)
except AttributeError:
return ImageDraw(im, mode)
# experimental access to the outline API
try:
Outline = Image.core.outline
except AttributeError:
Outline = None
def getdraw(im=None, hints=None):
def getdraw(
im: Image.Image | None = None, hints: list[str] | None = None
) -> tuple[ImageDraw2.Draw | None, ModuleType]:
"""
:param im: The image to draw in.
:param hints: An optional list of hints. Deprecated.
@ -913,9 +995,8 @@ def getdraw(im=None, hints=None):
deprecate("'hints' parameter", 12)
from . import ImageDraw2
if im:
im = ImageDraw2.Draw(im)
return im, ImageDraw2
draw = ImageDraw2.Draw(im) if im is not None else None
return draw, ImageDraw2
def floodfill(
@ -926,7 +1007,9 @@ def floodfill(
thresh: float = 0,
) -> 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 xy: Seed position (a 2-item coordinate tuple). See
@ -944,6 +1027,7 @@ def floodfill(
# based on an implementation by Eric S. Raymond
# amended by yo1995 @20180806
pixel = image.load()
assert pixel is not None
x, y = xy
try:
background = pixel[x, y]
@ -981,12 +1065,12 @@ def floodfill(
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]]:
"""
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.
(e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``)
:param n_sides: Number of sides
@ -1024,7 +1108,7 @@ def _compute_regular_polygon_vertices(
# 1. Error Handling
# 1.1 Check `n_sides` has an appropriate value
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)
if n_sides < 3:
msg = "n_sides should be an int > 2"
@ -1036,9 +1120,24 @@ def _compute_regular_polygon_vertices(
raise TypeError(msg)
if len(bounding_circle) == 3:
*centroid, polygon_radius = bounding_circle
elif len(bounding_circle) == 2:
centroid, polygon_radius = bounding_circle
if not all(isinstance(i, (int, float)) for i in bounding_circle):
msg = "bounding_circle should only contain numeric data"
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:
msg = (
"bounding_circle should contain 2D coordinates "
@ -1046,25 +1145,17 @@ def _compute_regular_polygon_vertices(
)
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:
msg = "bounding_circle radius should be > 0"
raise ValueError(msg)
# 1.3 Check `rotation` has an appropriate value
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)
# 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 (
round(
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]
return _apply_rotation(start_point, angle)

View File

@ -24,7 +24,10 @@
"""
from __future__ import annotations
from typing import BinaryIO
from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
from ._typing import StrOrBytesPath
class Pen:
@ -45,7 +48,9 @@ class Brush:
class Font:
"""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
self.color = ImageColor.getrgb(color)
self.font = ImageFont.truetype(file, size)
@ -56,8 +61,16 @@ class Draw:
(Experimental) WCK-style drawing interface
"""
def __init__(self, image, size=None, color=None):
if not hasattr(image, "im"):
def __init__(
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)
self.draw = ImageDraw.Draw(image)
self.image = image

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -152,7 +152,7 @@ def _toqclass_helper(im):
elif im.mode == "RGBA":
data = im.tobytes("raw", "BGRA")
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)
format = qt_format.Format_Grayscale16
@ -196,7 +196,7 @@ if qt_is_installed:
self.setColorTable(im_data["colortable"])
def toqimage(im):
def toqimage(im) -> ImageQt:
return ImageQt(im)

View File

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

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