Merge branch 'main' into wrap

This commit is contained in:
Andrew Murray 2026-01-02 20:44:12 +11:00
commit 1918c6811d
95 changed files with 672 additions and 442 deletions

View File

@ -2,55 +2,31 @@ name: Lint
on: [push, pull_request, workflow_dispatch]
permissions: {}
env:
FORCE_COLOR: 1
permissions:
contents: read
PREK_COLOR: always
RUFF_OUTPUT_FORMAT: github
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
lint:
runs-on: ubuntu-latest
name: Lint
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: pre-commit cache
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }}
restore-keys: |
lint-pre-commit-
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.x"
cache: pip
cache-dependency-path: "setup.py"
- name: Build system information
run: python3 .github/workflows/system-info.py
- name: Install dependencies
run: |
python3 -m pip install -U pip
python3 -m pip install -U tox
- name: Lint
run: tox -e lint
env:
PRE_COMMIT_COLOR: always
- name: Mypy
run: tox -e mypy
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Lint
run: uvx --with tox-uv tox -e lint
- name: Mypy
run: uvx --with tox-uv tox -e mypy

View File

@ -31,15 +31,16 @@ env:
jobs:
build:
runs-on: windows-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14", "3.15"]
architecture: ["x64"]
os: ["windows-latest"]
include:
# Test the oldest Python on 32-bit
- { python-version: "3.10", architecture: "x86" }
- { python-version: "3.10", architecture: "x86", os: "windows-2022" }
timeout-minutes: 45
@ -83,7 +84,7 @@ jobs:
python3 -m pip install --upgrade pip
- name: Install CPython dependencies
if: "!contains(matrix.python-version, 'pypy') && !contains(matrix.python-version, '3.14') && matrix.architecture != 'x86'"
if: "!contains(matrix.python-version, 'pypy') && matrix.architecture != 'x86'"
run: |
python3 -m pip install PyQt6

View File

@ -95,19 +95,15 @@ if [[ -n "$IOS_SDK" ]]; then
else
FREETYPE_VERSION=2.14.1
fi
HARFBUZZ_VERSION=12.2.0
HARFBUZZ_VERSION=12.3.0
LIBPNG_VERSION=1.6.53
JPEGTURBO_VERSION=3.1.2
JPEGTURBO_VERSION=3.1.3
OPENJPEG_VERSION=2.5.4
XZ_VERSION=5.8.1
XZ_VERSION=5.8.2
ZSTD_VERSION=1.5.7
TIFF_VERSION=4.7.1
LCMS2_VERSION=2.17
if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "aarch64" ]]; then
ZLIB_NG_VERSION=2.2.5
else
ZLIB_NG_VERSION=2.3.1
fi
ZLIB_NG_VERSION=2.3.2
LIBWEBP_VERSION=1.6.0
BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.17.0
@ -150,11 +146,7 @@ function build_zlib_ng {
ORIGINAL_HOST_CONFIGURE_FLAGS=$HOST_CONFIGURE_FLAGS
unset HOST_CONFIGURE_FLAGS
if [[ "$ZLIB_NG_VERSION" == 2.2.5 ]]; then
build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --zlib-compat
else
build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --installnamedir=$BUILD_PREFIX/lib --zlib-compat
fi
build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --installnamedir=$BUILD_PREFIX/lib --zlib-compat
HOST_CONFIGURE_FLAGS=$ORIGINAL_HOST_CONFIGURE_FLAGS
touch zlib-stamp

1
.github/zizmor.yml vendored
View File

@ -1,4 +1,3 @@
# Configuration for the zizmor static analysis tool, run via pre-commit in CI
# https://docs.zizmor.sh/configuration/
rules:
obfuscation:

View File

@ -76,7 +76,7 @@ repos:
rev: v0.24.1
hooks:
- id: validate-pyproject
additional_dependencies: [trove-classifiers>=2024.10.12]
additional_dependencies: [tomli, trove-classifiers>=2024.10.12]
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.7.0

Binary file not shown.

View File

@ -2,7 +2,7 @@
NotoNastaliqUrdu-Regular.ttf and NotoSansSymbols-Regular.ttf, from https://github.com/googlei18n/noto-fonts
NotoSans-Regular.ttf, from https://www.google.com/get/noto/
NotoSansJP-Thin.otf, from https://www.google.com/get/noto/help/cjk/
AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype
AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype. AdobeVFPrototypeDuplicates.ttf is a modified version of this
TINY5x3GX.ttf, from http://velvetyne.fr/fonts/tiny
ArefRuqaa-Regular.ttf, from https://github.com/google/fonts/tree/master/ofl/arefruqaa
ter-x20b.pcf, from http://terminus-font.sourceforge.net/

View File

@ -55,8 +55,8 @@ def convert_to_comparable(
if a.mode == "P":
new_a = Image.new("L", a.size)
new_b = Image.new("L", b.size)
new_a.putdata(a.getdata())
new_b.putdata(b.getdata())
new_a.putdata(a.get_flattened_data())
new_b.putdata(b.get_flattened_data())
elif a.mode == "I;16":
new_a = a.convert("I")
new_b = b.convert("I")
@ -104,10 +104,9 @@ def assert_image_equal_tofile(
msg: str | None = None,
mode: str | None = None,
) -> None:
with Image.open(filename) as img:
if mode:
img = img.convert(mode)
assert_image_equal(a, img, msg)
with Image.open(filename) as im:
converted_im = im.convert(mode) if mode else im
assert_image_equal(a, converted_im, msg)
def assert_image_similar(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 B

After

Width:  |  Height:  |  Size: 79 B

View File

@ -95,16 +95,16 @@ def test_good() -> None:
for f in get_files("g"):
try:
with Image.open(f) as im:
im.load()
with Image.open(get_compare(f)) as compare:
compare.load()
if im.mode == "P":
# assert image similar doesn't really work
# with paletized image, since the palette might
# be differently ordered for an equivalent image.
im = im.convert("RGBA")
compare = compare.convert("RGBA")
assert_image_similar(im, compare, 5)
# assert image similar doesn't really work
# with paletized image, since the palette might
# be differently ordered for an equivalent image.
im_converted = im.convert("RGBA") if im.mode == "P" else im
compare_converted = (
compare.convert("RGBA") if im.mode == "P" else compare
)
assert_image_similar(im_converted, compare_converted, 5)
except Exception as msg:
# there are three here that are unsupported:

View File

@ -28,9 +28,13 @@ def box_blur(image: Image.Image, radius: float = 1, n: int = 1) -> Image.Image:
def assert_image(im: Image.Image, data: list[list[int]], delta: int = 0) -> None:
it = iter(im.getdata())
it = iter(im.get_flattened_data())
for data_row in data:
im_row = [next(it) for _ in range(im.size[0])]
im_row = []
for _ in range(im.width):
im_v = next(it)
assert isinstance(im_v, (int, float))
im_row.append(im_v)
if any(abs(data_v - im_v) > delta for data_v, im_v in zip(data_row, im_row)):
assert im_row == data_row
with pytest.raises(StopIteration):

View File

@ -278,25 +278,25 @@ def test_apng_mode() -> None:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGB")
assert im.getpixel((0, 0)) == (0, 255, 0)
assert im.getpixel((64, 32)) == (0, 255, 0)
im_rgb = im.convert("RGB")
assert im_rgb.getpixel((0, 0)) == (0, 255, 0)
assert im_rgb.getpixel((64, 32)) == (0, 255, 0)
with Image.open("Tests/images/apng/mode_palette_alpha.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGBA")
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
im_rgba = im.convert("RGBA")
assert im_rgba.getpixel((0, 0)) == (0, 255, 0, 255)
assert im_rgba.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGBA")
assert im.getpixel((0, 0)) == (0, 0, 255, 128)
assert im.getpixel((64, 32)) == (0, 0, 255, 128)
im_rgba = im.convert("RGBA")
assert im_rgba.getpixel((0, 0)) == (0, 0, 255, 128)
assert im_rgba.getpixel((64, 32)) == (0, 0, 255, 128)
def test_apng_chunk_errors() -> None:
@ -518,6 +518,24 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
assert im.info["duration"] == 600
def test_apng_save_duration_float(tmp_path: Path) -> None:
test_file = tmp_path / "temp.png"
im = Image.new("1", (1, 1))
im2 = Image.new("1", (1, 1), 1)
im.save(test_file, save_all=True, append_images=[im2], duration=0.5)
with Image.open(test_file) as reloaded:
assert reloaded.info["duration"] == 0.5
def test_apng_save_large_duration(tmp_path: Path) -> None:
test_file = tmp_path / "temp.png"
im = Image.new("1", (1, 1))
im2 = Image.new("1", (1, 1), 1)
with pytest.raises(ValueError, match="cannot write duration"):
im.save(test_file, save_all=True, append_images=[im2], duration=65536000)
def test_apng_save_disposal(tmp_path: Path) -> None:
test_file = tmp_path / "temp.png"
size = (128, 64)

View File

@ -121,7 +121,6 @@ class TestFileAvif:
assert image.size == (128, 128)
assert image.format == "AVIF"
assert image.get_format_mimetype() == "image/avif"
image.getdata()
# generated with:
# avifdec hopper.avif hopper_avif_write.png
@ -143,7 +142,6 @@ class TestFileAvif:
assert reloaded.mode == "RGB"
assert reloaded.size == (128, 128)
assert reloaded.format == "AVIF"
reloaded.getdata()
# avifdec hopper.avif avif/hopper_avif_write.png
assert_image_similar_tofile(

View File

@ -165,9 +165,9 @@ def test_rgba_bitfields() -> None:
with Image.open("Tests/images/rgb32bf-rgba.bmp") as im:
# So before the comparing the image, swap the channels
b, g, r = im.split()[1:]
im = Image.merge("RGB", (r, g, b))
im_rgb = Image.merge("RGB", (r, g, b))
assert_image_equal_tofile(im, "Tests/images/bmp/q/rgb32bf-xbgr.bmp")
assert_image_equal_tofile(im_rgb, "Tests/images/bmp/q/rgb32bf-xbgr.bmp")
# This test image has been manually hexedited
# to change the bitfield compression in the header from XBGR to ABGR

View File

@ -61,6 +61,7 @@ def test_handler(tmp_path: Path) -> None:
def load(self, im: ImageFile.StubImageFile) -> Image.Image:
self.loaded = True
assert im.fp is not None
im.fp.close()
return Image.new("RGB", (1, 1))

View File

@ -57,7 +57,7 @@ TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds"
def test_sanity_dxt1_bc1(image_path: str) -> None:
"""Check DXT1 and BC1 images can be opened"""
with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target:
target = target.convert("RGBA")
target_rgba = target.convert("RGBA")
with Image.open(image_path) as im:
im.load()
@ -65,7 +65,7 @@ def test_sanity_dxt1_bc1(image_path: str) -> None:
assert im.mode == "RGBA"
assert im.size == (256, 256)
assert_image_equal(im, target)
assert_image_equal(im, target_rgba)
def test_sanity_dxt3() -> None:
@ -520,9 +520,9 @@ def test_save_dx10_bc5(tmp_path: Path) -> None:
im.save(out, pixel_format="BC5")
assert_image_similar_tofile(im, out, 9.56)
im = hopper("L")
im_l = hopper("L")
with pytest.raises(OSError, match="only RGB mode can be written as BC5"):
im.save(out, pixel_format="BC5")
im_l.save(out, pixel_format="BC5")
@pytest.mark.parametrize(

View File

@ -265,9 +265,9 @@ def test_bytesio_object() -> None:
img.load()
with Image.open(FILE1_COMPARE) as image1_scale1_compare:
image1_scale1_compare = image1_scale1_compare.convert("RGB")
image1_scale1_compare.load()
assert_image_similar(img, image1_scale1_compare, 5)
image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB")
image1_scale1_compare_rgb.load()
assert_image_similar(img, image1_scale1_compare_rgb, 5)
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
@ -301,17 +301,17 @@ def test_render_scale1() -> None:
with Image.open(FILE1) as image1_scale1:
image1_scale1.load()
with Image.open(FILE1_COMPARE) as image1_scale1_compare:
image1_scale1_compare = image1_scale1_compare.convert("RGB")
image1_scale1_compare.load()
assert_image_similar(image1_scale1, image1_scale1_compare, 5)
image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB")
image1_scale1_compare_rgb.load()
assert_image_similar(image1_scale1, image1_scale1_compare_rgb, 5)
# Non-zero bounding box
with Image.open(FILE2) as image2_scale1:
image2_scale1.load()
with Image.open(FILE2_COMPARE) as image2_scale1_compare:
image2_scale1_compare = image2_scale1_compare.convert("RGB")
image2_scale1_compare.load()
assert_image_similar(image2_scale1, image2_scale1_compare, 10)
image2_scale1_compare_rgb = image2_scale1_compare.convert("RGB")
image2_scale1_compare_rgb.load()
assert_image_similar(image2_scale1, image2_scale1_compare_rgb, 10)
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
@ -324,18 +324,16 @@ def test_render_scale2() -> None:
assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile)
image1_scale2.load(scale=2)
with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare:
image1_scale2_compare = image1_scale2_compare.convert("RGB")
image1_scale2_compare.load()
assert_image_similar(image1_scale2, image1_scale2_compare, 5)
image1_scale2_compare_rgb = image1_scale2_compare.convert("RGB")
assert_image_similar(image1_scale2, image1_scale2_compare_rgb, 5)
# Non-zero bounding box
with Image.open(FILE2) as image2_scale2:
assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile)
image2_scale2.load(scale=2)
with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare:
image2_scale2_compare = image2_scale2_compare.convert("RGB")
image2_scale2_compare.load()
assert_image_similar(image2_scale2, image2_scale2_compare, 10)
image2_scale2_compare_rgb = image2_scale2_compare.convert("RGB")
assert_image_similar(image2_scale2, image2_scale2_compare_rgb, 10)
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
@ -345,8 +343,8 @@ def test_render_scale2() -> None:
def test_resize(filename: str) -> None:
with Image.open(filename) as im:
new_size = (100, 100)
im = im.resize(new_size)
assert im.size == new_size
im_resized = im.resize(new_size)
assert im_resized.size == new_size
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")

View File

@ -327,14 +327,13 @@ def test_loading_multiple_palettes(path: str, mode: str) -> None:
im.seek(1)
assert im.mode == mode
if mode == "RGBA":
im = im.convert("RGB")
im_rgb = im.convert("RGB") if mode == "RGBA" else im
# Check a color only from the old palette
assert im.getpixel((0, 0)) == original_color
assert im_rgb.getpixel((0, 0)) == original_color
# Check a color from the new palette
assert im.getpixel((24, 24)) not in first_frame_colors
assert im_rgb.getpixel((24, 24)) not in first_frame_colors
def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None:
@ -354,16 +353,16 @@ def test_palette_handling(tmp_path: Path) -> None:
# see https://github.com/python-pillow/Pillow/issues/513
with Image.open(TEST_GIF) as im:
im = im.convert("RGB")
im_rgb = im.convert("RGB")
im = im.resize((100, 100), Image.Resampling.LANCZOS)
im2 = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256)
im_rgb = im_rgb.resize((100, 100), Image.Resampling.LANCZOS)
im_p = im_rgb.convert("P", palette=Image.Palette.ADAPTIVE, colors=256)
f = tmp_path / "temp.gif"
im2.save(f, optimize=True)
f = tmp_path / "temp.gif"
im_p.save(f, optimize=True)
with Image.open(f) as reloaded:
assert_image_similar(im, reloaded.convert("RGB"), 10)
assert_image_similar(im_rgb, reloaded.convert("RGB"), 10)
def test_palette_434(tmp_path: Path) -> None:
@ -383,35 +382,36 @@ def test_palette_434(tmp_path: Path) -> None:
with roundtrip(im, optimize=True) as reloaded:
assert_image_similar(im, reloaded, 1)
im = im.convert("RGB")
# check automatic P conversion
with roundtrip(im) as reloaded:
reloaded = reloaded.convert("RGB")
assert_image_equal(im, reloaded)
im_rgb = im.convert("RGB")
# check automatic P conversion
with roundtrip(im_rgb) as reloaded:
reloaded = reloaded.convert("RGB")
assert_image_equal(im_rgb, reloaded)
@pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available")
def test_save_netpbm_bmp_mode(tmp_path: Path) -> None:
with Image.open(TEST_GIF) as img:
img = img.convert("RGB")
img_rgb = img.convert("RGB")
tempfile = str(tmp_path / "temp.gif")
b = BytesIO()
GifImagePlugin._save_netpbm(img, b, tempfile)
with Image.open(tempfile) as reloaded:
assert_image_similar(img, reloaded.convert("RGB"), 0)
tempfile = str(tmp_path / "temp.gif")
b = BytesIO()
GifImagePlugin._save_netpbm(img_rgb, b, tempfile)
with Image.open(tempfile) as reloaded:
assert_image_similar(img_rgb, reloaded.convert("RGB"), 0)
@pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available")
def test_save_netpbm_l_mode(tmp_path: Path) -> None:
with Image.open(TEST_GIF) as img:
img = img.convert("L")
img_l = img.convert("L")
tempfile = str(tmp_path / "temp.gif")
b = BytesIO()
GifImagePlugin._save_netpbm(img, b, tempfile)
GifImagePlugin._save_netpbm(img_l, b, tempfile)
with Image.open(tempfile) as reloaded:
assert_image_similar(img, reloaded.convert("L"), 0)
assert_image_similar(img_l, reloaded.convert("L"), 0)
def test_seek() -> None:
@ -1038,9 +1038,9 @@ def test_webp_background(tmp_path: Path) -> None:
im.save(out)
# Test non-opaque WebP background
im = Image.new("L", (100, 100), "#000")
im.info["background"] = (0, 0, 0, 0)
im.save(out)
im2 = Image.new("L", (100, 100), "#000")
im2.info["background"] = (0, 0, 0, 0)
im2.save(out)
def test_comment(tmp_path: Path) -> None:
@ -1048,16 +1048,16 @@ def test_comment(tmp_path: Path) -> None:
assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0"
out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
im.info["comment"] = b"Test comment text"
im.save(out)
im2 = Image.new("L", (100, 100), "#000")
im2.info["comment"] = b"Test comment text"
im2.save(out)
with Image.open(out) as reread:
assert reread.info["comment"] == im.info["comment"]
assert reread.info["comment"] == im2.info["comment"]
im.info["comment"] = "Test comment text"
im.save(out)
im2.info["comment"] = "Test comment text"
im2.save(out)
with Image.open(out) as reread:
assert reread.info["comment"] == im.info["comment"].encode()
assert reread.info["comment"] == im2.info["comment"].encode()
# Test that GIF89a is used for comments
assert reread.info["version"] == b"GIF89a"

View File

@ -61,6 +61,7 @@ def test_handler(tmp_path: Path) -> None:
def load(self, im: ImageFile.ImageFile) -> Image.Image:
self.loaded = True
assert im.fp is not None
im.fp.close()
return Image.new("RGB", (1, 1))

View File

@ -63,6 +63,7 @@ def test_handler(tmp_path: Path) -> None:
def load(self, im: ImageFile.ImageFile) -> Image.Image:
self.loaded = True
assert im.fp is not None
im.fp.close()
return Image.new("RGB", (1, 1))

View File

@ -6,7 +6,7 @@ import pytest
from PIL import Image, IptcImagePlugin, TiffImagePlugin, TiffTags
from .helper import assert_image_equal, hopper
from .helper import assert_image_equal
TEST_FILE = "Tests/images/iptc.jpg"
@ -85,7 +85,7 @@ def test_getiptcinfo() -> None:
def test_getiptcinfo_jpg_none() -> None:
# Arrange
with hopper() as im:
with Image.open("Tests/images/hopper.jpg") as im:
# Act
iptc = IptcImagePlugin.getiptcinfo(im)

View File

@ -1133,8 +1133,9 @@ class TestFileCloseW32:
im.save(tmpfile)
im = Image.open(tmpfile)
assert im.fp is not None
assert not im.fp.closed
fp = im.fp
assert not fp.closed
with pytest.raises(OSError):
os.remove(tmpfile)
im.load()

View File

@ -164,7 +164,7 @@ def test_reduce() -> None:
with Image.open("Tests/images/test-card-lossless.jp2") as im:
assert callable(im.reduce)
im.reduce = 2
im.reduce = 2 # type: ignore[assignment, method-assign]
assert im.reduce == 2
im.load()

View File

@ -42,7 +42,6 @@ class LibTiffTestCase:
# Does the data actually load
im.load()
im.getdata()
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im._compression == "group4"
@ -516,12 +515,12 @@ class TestFileLibTiff(LibTiffTestCase):
# and save to compressed tif.
out = tmp_path / "temp.tif"
with Image.open("Tests/images/pport_g4.tif") as im:
im = im.convert("L")
im_l = im.convert("L")
im = im.filter(ImageFilter.GaussianBlur(4))
im.save(out, compression="tiff_adobe_deflate")
im_l = im_l.filter(ImageFilter.GaussianBlur(4))
im_l.save(out, compression="tiff_adobe_deflate")
assert_image_equal_tofile(im, out)
assert_image_equal_tofile(im_l, out)
def test_compressions(self, tmp_path: Path) -> None:
# Test various tiff compressions and assert similar image content but reduced
@ -610,8 +609,9 @@ class TestFileLibTiff(LibTiffTestCase):
im.save(out, compression=compression)
def test_fp_leak(self) -> None:
im: Image.Image | None = Image.open("Tests/images/hopper_g4_500.tif")
im: ImageFile.ImageFile | None = Image.open("Tests/images/hopper_g4_500.tif")
assert im is not None
assert im.fp is not None
fn = im.fp.fileno()
os.fstat(fn)
@ -1087,8 +1087,10 @@ class TestFileLibTiff(LibTiffTestCase):
data = data[:102] + b"\x02" + data[103:]
with Image.open(io.BytesIO(data)) as im:
im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png")
im_transposed = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
assert_image_equal_tofile(
im_transposed, "Tests/images/old-style-jpeg-compression.png"
)
def test_open_missing_samplesperpixel(self) -> None:
with Image.open(

View File

@ -101,12 +101,13 @@ class TestFilePng:
assert im.get_format_mimetype() == "image/png"
for mode in ["1", "L", "P", "RGB", "I;16", "I;16B"]:
im = hopper(mode)
im.save(test_file)
im1 = hopper(mode)
im1.save(test_file)
with Image.open(test_file) as reloaded:
if mode == "I;16B":
reloaded = reloaded.convert(mode)
assert_image_equal(reloaded, im)
converted_reloaded = (
reloaded.convert(mode) if mode == "I;16B" else reloaded
)
assert_image_equal(converted_reloaded, im1)
def test_invalid_file(self) -> None:
invalid_file = "Tests/images/flower.jpg"
@ -225,11 +226,11 @@ class TestFilePng:
test_file = "Tests/images/pil123p.png"
with Image.open(test_file) as im:
assert_image(im, "P", (162, 150))
im = im.convert("RGBA")
assert_image(im, "RGBA", (162, 150))
im_rgba = im.convert("RGBA")
assert_image(im_rgba, "RGBA", (162, 150))
# image has 124 unique alpha values
colors = im.getchannel("A").getcolors()
colors = im_rgba.getchannel("A").getcolors()
assert colors is not None
assert len(colors) == 124
@ -239,11 +240,11 @@ class TestFilePng:
assert im.info["transparency"] == (0, 255, 52)
assert_image(im, "RGB", (64, 64))
im = im.convert("RGBA")
assert_image(im, "RGBA", (64, 64))
im_rgba = im.convert("RGBA")
assert_image(im_rgba, "RGBA", (64, 64))
# image has 876 transparent pixels
colors = im.getchannel("A").getcolors()
colors = im_rgba.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == 876
@ -262,11 +263,11 @@ class TestFilePng:
assert len(im.info["transparency"]) == 256
assert_image(im, "P", (162, 150))
im = im.convert("RGBA")
assert_image(im, "RGBA", (162, 150))
im_rgba = im.convert("RGBA")
assert_image(im_rgba, "RGBA", (162, 150))
# image has 124 unique alpha values
colors = im.getchannel("A").getcolors()
colors = im_rgba.getchannel("A").getcolors()
assert colors is not None
assert len(colors) == 124
@ -285,13 +286,13 @@ class TestFilePng:
assert im.info["transparency"] == 164
assert im.getpixel((31, 31)) == 164
assert_image(im, "P", (64, 64))
im = im.convert("RGBA")
assert_image(im, "RGBA", (64, 64))
im_rgba = im.convert("RGBA")
assert_image(im_rgba, "RGBA", (64, 64))
assert im.getpixel((31, 31)) == (0, 255, 52, 0)
assert im_rgba.getpixel((31, 31)) == (0, 255, 52, 0)
# image has 876 transparent pixels
colors = im.getchannel("A").getcolors()
colors = im_rgba.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == 876
@ -822,7 +823,7 @@ class TestFilePng:
monkeypatch.setattr(sys, "stdout", mystdout)
with Image.open(TEST_PNG_FILE) as im:
im.save(sys.stdout, "PNG")
im.save(sys.stdout, "PNG") # type: ignore[arg-type]
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer

View File

@ -389,7 +389,7 @@ def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "stdout", mystdout)
with Image.open(TEST_FILE) as im:
im.save(sys.stdout, "PPM")
im.save(sys.stdout, "PPM") # type: ignore[arg-type]
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer

View File

@ -971,6 +971,7 @@ class TestFileTiff:
im = Image.open(tmpfile)
fp = im.fp
assert fp is not None
assert not fp.closed
im.load()
assert fp.closed
@ -984,6 +985,7 @@ class TestFileTiff:
with open(tmpfile, "rb") as f:
im = Image.open(f)
fp = im.fp
assert fp is not None
assert not fp.closed
im.load()
assert not fp.closed
@ -1034,8 +1036,9 @@ class TestFileTiffW32:
im.save(tmpfile)
im = Image.open(tmpfile)
assert im.fp is not None
assert not im.fp.closed
fp = im.fp
assert not fp.closed
with pytest.raises(OSError):
os.remove(tmpfile)
im.load()

View File

@ -60,7 +60,6 @@ class TestFileWebp:
assert image.size == (128, 128)
assert image.format == "WEBP"
image.load()
image.getdata()
# generated with:
# dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm
@ -77,7 +76,6 @@ class TestFileWebp:
assert image.size == (128, 128)
assert image.format == "WEBP"
image.load()
image.getdata()
if mode == self.rgb_mode:
# generated with: dwebp -ppm temp.webp -o hopper_webp_write.ppm

View File

@ -29,7 +29,6 @@ def test_read_rgba() -> None:
assert image.size == (200, 150)
assert image.format == "WEBP"
image.load()
image.getdata()
image.tobytes()
@ -60,7 +59,6 @@ def test_write_lossless_rgb(tmp_path: Path) -> None:
assert image.size == pil_image.size
assert image.format == "WEBP"
image.load()
image.getdata()
assert_image_equal(image, pil_image)
@ -83,7 +81,6 @@ def test_write_rgba(tmp_path: Path) -> None:
assert image.size == (10, 10)
assert image.format == "WEBP"
image.load()
image.getdata()
assert_image_similar(image, pil_image, 1.0)
@ -133,7 +130,6 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None:
assert image.format == "WEBP"
image.load()
image.getdata()
with Image.open(file_path) as im:
target = im.convert("RGBA")

View File

@ -24,6 +24,5 @@ def test_write_lossless_rgb(tmp_path: Path) -> None:
assert image.size == (128, 128)
assert image.format == "WEBP"
image.load()
image.getdata()
assert_image_equal(image, hopper(RGB_MODE))

View File

@ -13,15 +13,15 @@ def test_white() -> None:
k = i.getpixel((0, 0))
L = i.getdata(0)
a = i.getdata(1)
b = i.getdata(2)
L = i.get_flattened_data(0)
a = i.get_flattened_data(1)
b = i.get_flattened_data(2)
assert k == (255, 128, 128)
assert list(L) == [255] * 100
assert list(a) == [128] * 100
assert list(b) == [128] * 100
assert L == (255,) * 100
assert a == (128,) * 100
assert b == (128,) * 100
def test_green() -> None:

View File

@ -1181,10 +1181,10 @@ class TestImageBytes:
assert reloaded.tobytes() == source_bytes
@pytest.mark.parametrize("mode", Image.MODES)
def test_getdata_putdata(self, mode: str) -> None:
def test_get_flattened_data_putdata(self, mode: str) -> None:
im = hopper(mode)
reloaded = Image.new(mode, im.size)
reloaded.putdata(im.getdata())
reloaded.putdata(im.get_flattened_data())
assert_image_equal(im, reloaded)

View File

@ -78,7 +78,7 @@ def test_fromarray() -> None:
},
)
out = Image.fromarray(wrapped)
return out.mode, out.size, list(i.getdata()) == list(out.getdata())
return out.mode, out.size, i.get_flattened_data() == out.get_flattened_data()
# assert test("1") == ("1", (128, 100), True)
assert test("L") == ("L", (128, 100), True)

View File

@ -95,10 +95,10 @@ def test_crop_zero() -> None:
cropped = im.crop((10, 10, 20, 20))
assert cropped.size == (10, 10)
assert cropped.getdata()[0] == (0, 0, 0)
assert cropped.getpixel((0, 0)) == (0, 0, 0)
im = Image.new("RGB", (0, 0))
cropped = im.crop((10, 10, 20, 20))
assert cropped.size == (10, 10)
assert cropped.getdata()[2] == (0, 0, 0)
assert cropped.getpixel((2, 0)) == (0, 0, 0)

View File

@ -1,23 +1,23 @@
from __future__ import annotations
import pytest
from PIL import Image
from .helper import hopper
def test_sanity() -> None:
data = hopper().getdata()
len(data)
list(data)
data = hopper().get_flattened_data()
assert len(data) == 128 * 128
assert data[0] == (20, 20, 70)
def test_mode() -> None:
def getdata(mode: str) -> tuple[float | tuple[int, ...] | None, int, int]:
im = hopper(mode).resize((32, 30), Image.Resampling.NEAREST)
data = im.getdata()
data = im.get_flattened_data()
return data[0], len(data), len(list(data))
assert getdata("1") == (0, 960, 960)
@ -28,3 +28,13 @@ def test_mode() -> None:
assert getdata("RGBA") == ((11, 13, 52, 255), 960, 960)
assert getdata("CMYK") == ((244, 242, 203, 0), 960, 960)
assert getdata("YCbCr") == ((16, 147, 123), 960, 960)
def test_deprecation() -> None:
im = hopper()
with pytest.warns(DeprecationWarning, match="getdata"):
data = im.getdata()
assert len(data) == 128 * 128
assert data[0] == (20, 20, 70)
assert list(data)[0] == (20, 20, 70)

View File

@ -38,6 +38,7 @@ def test_close_after_load(caplog: pytest.LogCaptureFixture) -> None:
def test_contextmanager() -> None:
fn = None
with Image.open("Tests/images/hopper.gif") as im:
assert im.fp is not None
fn = im.fp.fileno()
os.fstat(fn)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import sys
from array import array
from typing import cast
import pytest
@ -12,21 +13,19 @@ from .helper import assert_image_equal, hopper
def test_sanity() -> None:
im1 = hopper()
for data in (im1.get_flattened_data(), im1.im):
im2 = Image.new(im1.mode, im1.size, 0)
im2.putdata(data)
data = list(im1.getdata())
assert_image_equal(im1, im2)
im2 = Image.new(im1.mode, im1.size, 0)
im2.putdata(data)
# readonly
im2 = Image.new(im1.mode, im2.size, 0)
im2.readonly = 1
im2.putdata(data)
assert_image_equal(im1, im2)
# readonly
im2 = Image.new(im1.mode, im2.size, 0)
im2.readonly = 1
im2.putdata(data)
assert not im2.readonly
assert_image_equal(im1, im2)
assert not im2.readonly
assert_image_equal(im1, im2)
def test_long_integers() -> None:
@ -60,22 +59,22 @@ def test_mode_with_L_with_float() -> None:
@pytest.mark.parametrize("mode", ("I", "I;16", "I;16L", "I;16B"))
def test_mode_i(mode: str) -> None:
src = hopper("L")
data = list(src.getdata())
data = src.get_flattened_data()
im = Image.new(mode, src.size, 0)
im.putdata(data, 2, 256)
target = [2 * elt + 256 for elt in data]
assert list(im.getdata()) == target
target = tuple(2 * elt + 256 for elt in cast(tuple[int, ...], data))
assert im.get_flattened_data() == target
def test_mode_F() -> None:
src = hopper("L")
data = list(src.getdata())
data = src.get_flattened_data()
im = Image.new("F", src.size, 0)
im.putdata(data, 2.0, 256.0)
target = [2.0 * float(elt) + 256.0 for elt in data]
assert list(im.getdata()) == target
target = tuple(2.0 * float(elt) + 256.0 for elt in cast(tuple[int, ...], data))
assert im.get_flattened_data() == target
def test_array_B() -> None:
@ -86,7 +85,7 @@ def test_array_B() -> None:
im = Image.new("L", (150, 100))
im.putdata(arr)
assert len(im.getdata()) == len(arr)
assert len(im.get_flattened_data()) == len(arr)
def test_array_F() -> None:
@ -97,7 +96,7 @@ def test_array_F() -> None:
arr = array("f", [0.0]) * 15000
im.putdata(arr)
assert len(im.getdata()) == len(arr)
assert len(im.get_flattened_data()) == len(arr)
def test_not_flattened() -> None:

View File

@ -160,7 +160,7 @@ class TestImagingCoreResize:
r = self.resize(Image.new("RGB", (0, 0), "white"), (212, 195), resample)
assert r.mode == "RGB"
assert r.size == (212, 195)
assert r.getdata()[0] == (0, 0, 0)
assert r.getpixel((0, 0)) == (0, 0, 0)
def test_unknown_filter(self) -> None:
with pytest.raises(ValueError):

View File

@ -250,14 +250,14 @@ class TestImageTransform:
def test_missing_method_data(self) -> None:
with hopper() as im:
with pytest.raises(ValueError):
im.transform((100, 100), None)
im.transform((100, 100), None) # type: ignore[arg-type]
@pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown"))
def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None:
with hopper() as im:
(w, h) = im.size
with pytest.raises(ValueError):
im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample)
im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) # type: ignore[arg-type]
class TestImageTransformAffine:

View File

@ -274,13 +274,13 @@ def test_simple_lab() -> None:
# not a linear luminance map. so L != 128:
assert k == (137, 128, 128)
l_data = i_lab.getdata(0)
a_data = i_lab.getdata(1)
b_data = i_lab.getdata(2)
l_data = i_lab.get_flattened_data(0)
a_data = i_lab.get_flattened_data(1)
b_data = i_lab.get_flattened_data(2)
assert list(l_data) == [137] * 100
assert list(a_data) == [128] * 100
assert list(b_data) == [128] * 100
assert l_data == (137,) * 100
assert a_data == (128,) * 100
assert b_data == (128,) * 100
def test_lab_color() -> None:

View File

@ -702,7 +702,7 @@ def test_variation_get(font: ImageFont.FreeTypeFont) -> None:
font.get_variation_axes()
font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf")
assert font.get_variation_names(), [
assert font.get_variation_names() == [
b"ExtraLight",
b"Light",
b"Regular",
@ -742,6 +742,21 @@ def test_variation_get(font: ImageFont.FreeTypeFont) -> None:
]
def test_variation_duplicates() -> None:
font = ImageFont.truetype("Tests/fonts/AdobeVFPrototypeDuplicates.ttf")
assert font.get_variation_names() == [
b"ExtraLight",
b"Light",
b"Regular",
b"Semibold",
b"Bold",
b"Black",
b"Black Medium Contrast",
b"Black High Contrast",
b"Default",
]
def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None:
im = Image.new("RGB", (100, 75), "white")
d = ImageDraw.Draw(im)

View File

@ -15,13 +15,10 @@ def string_to_img(image_string: str) -> Image.Image:
rows = [s for s in image_string.replace(" ", "").split("\n") if len(s)]
height = len(rows)
width = len(rows[0])
im = Image.new("L", (width, height))
for i in range(width):
for j in range(height):
c = rows[j][i]
v = c in "X1"
im.putpixel((i, j), v)
im = Image.new("1", (width, height))
for x in range(width):
for y in range(height):
im.putpixel((x, y), rows[y][x] in "X1")
return im
@ -42,10 +39,10 @@ def img_to_string(im: Image.Image) -> str:
"""Turn a (small) binary image into a string representation"""
chars = ".1"
result = []
for r in range(im.height):
for y in range(im.height):
line = ""
for c in range(im.width):
value = im.getpixel((c, r))
for x in range(im.width):
value = im.getpixel((x, y))
assert not isinstance(value, tuple)
assert value is not None
line += chars[value > 0]
@ -165,10 +162,12 @@ def test_edge() -> None:
)
def test_corner() -> None:
@pytest.mark.parametrize("mode", ("1", "L"))
def test_corner(mode: str) -> None:
# Create a corner detector pattern
mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"])
count, Aout = mop.apply(A)
image = A.convert(mode) if mode == "L" else A
count, Aout = mop.apply(image)
assert count == 5
assert_img_equal_img_string(
Aout,
@ -184,7 +183,7 @@ def test_corner() -> None:
)
# Test the coordinate counting with the same operator
coords = mop.match(A)
coords = mop.match(image)
assert len(coords) == 4
assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4))
@ -232,14 +231,14 @@ def test_negate() -> None:
def test_incorrect_mode() -> None:
im = hopper("RGB")
im = hopper()
mop = ImageMorph.MorphOp(op_name="erosion8")
with pytest.raises(ValueError, match="Image mode must be L"):
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
mop.apply(im)
with pytest.raises(ValueError, match="Image mode must be L"):
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
mop.match(im)
with pytest.raises(ValueError, match="Image mode must be L"):
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
mop.get_on_pixels(im)
@ -281,6 +280,11 @@ def test_pattern_syntax_error(pattern: str) -> None:
lb.build_lut()
def test_build_default_lut() -> None:
lb = ImageMorph.LutBuilder(op_name="corner")
assert lb.build_default_lut() == lb.lut
def test_load_invalid_mrl() -> None:
# Arrange
invalid_mrl = "Tests/images/hopper.png"

View File

@ -457,9 +457,9 @@ def test_exif_transpose() -> None:
assert 0x0112 not in transposed_im.getexif()
# Orientation set directly on Image.Exif
im = hopper()
im.getexif()[0x0112] = 3
transposed_im = ImageOps.exif_transpose(im)
im1 = hopper()
im1.getexif()[0x0112] = 3
transposed_im = ImageOps.exif_transpose(im1)
assert 0x0112 not in transposed_im.getexif()

View File

@ -20,21 +20,19 @@ TEST_IMAGE_SIZE = (10, 10)
def test_numpy_to_image() -> None:
def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image:
data = tuple(range(100))
if bands == 1:
if boolean:
data = [0, 255] * 50
else:
data = list(range(100))
data = (0, 255) * 50
a = numpy.array(data, dtype=dtype)
a.shape = TEST_IMAGE_SIZE
i = Image.fromarray(a)
assert list(i.getdata()) == data
assert i.get_flattened_data() == data
else:
data = list(range(100))
a = numpy.array([[x] * bands for x in data], dtype=dtype)
a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands
i = Image.fromarray(a)
assert list(i.getchannel(0).getdata()) == list(range(100))
assert i.get_flattened_data(0) == tuple(range(100))
return i
# Check supported 1-bit integer formats
@ -191,7 +189,7 @@ def test_putdata() -> None:
arr = numpy.zeros((15000,), numpy.float32)
im.putdata(arr)
assert len(im.getdata()) == len(arr)
assert len(im.get_flattened_data()) == len(arr)
def test_resize() -> None:
@ -248,7 +246,7 @@ def test_bool() -> None:
a[0][0] = True
im2 = Image.fromarray(a)
assert im2.getdata()[0] == 255
assert im2.getpixel((0, 0)) == 255
def test_no_resource_warning_for_numpy_array() -> None:

View File

@ -19,30 +19,28 @@ def helper_pickle_file(
# Arrange
with Image.open(test_file) as im:
filename = tmp_path / "temp.pkl"
if mode:
im = im.convert(mode)
converted_im = im.convert(mode) if mode else im
# Act
with open(filename, "wb") as f:
pickle.dump(im, f, protocol)
pickle.dump(converted_im, f, protocol)
with open(filename, "rb") as f:
loaded_im = pickle.load(f)
# Assert
assert im == loaded_im
assert converted_im == loaded_im
def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> None:
with Image.open(test_file) as im:
if mode:
im = im.convert(mode)
converted_im = im.convert(mode) if mode else im
# Act
dumped_string = pickle.dumps(im, protocol)
dumped_string = pickle.dumps(converted_im, protocol)
loaded_im = pickle.loads(dumped_string)
# Assert
assert im == loaded_im
assert converted_im == loaded_im
@pytest.mark.parametrize(

View File

@ -73,6 +73,16 @@ Image._show
``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15).
Use :py:meth:`~PIL.ImageShow.show` instead.
Image getdata()
~~~~~~~~~~~~~~~
.. deprecated:: 12.1.0
:py:meth:`~PIL.Image.Image.getdata` has been deprecated.
:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is
identical, except that it returns a tuple of pixel values, instead of an internal
Pillow data type.
Removed features
----------------

View File

@ -213,6 +213,7 @@ class DdsImageFile(ImageFile.ImageFile):
format_description = "DirectDraw Surface"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not a DDS file"
raise SyntaxError(msg)

View File

@ -999,7 +999,7 @@ where applicable:
The number of times to loop this APNG, 0 indicates infinite looping.
**duration**
The time to display this APNG frame (in milliseconds).
The time to display this APNG frame (in milliseconds), given as a float.
.. note::
@ -1041,9 +1041,8 @@ following parameters can also be set:
Defaults to 0.
**duration**
Integer (or list or tuple of integers) length of time to display this APNG frame
(in milliseconds).
Defaults to 0.
The length of time (or list or tuple of lengths of time) to display this APNG frame
(in milliseconds). Defaults to 0.
**disposal**
An integer (or list or tuple of integers) specifying the APNG disposal

View File

@ -53,8 +53,8 @@ These platforms are built and tested for every change.
| | | s390x |
+----------------------------------+----------------------------+---------------------+
| Windows Server 2022 | 3.10 | x86 |
| +----------------------------+---------------------+
| | 3.11, 3.12, 3.13, 3.14, | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 |
| | PyPy3 | |
| +----------------------------+---------------------+
| | 3.12 (MinGW) | x86-64 |

View File

@ -191,6 +191,7 @@ This helps to get the bounding box coordinates of the input image::
.. automethod:: PIL.Image.Image.getchannel
.. automethod:: PIL.Image.Image.getcolors
.. automethod:: PIL.Image.Image.getdata
.. automethod:: PIL.Image.Image.get_flattened_data
.. automethod:: PIL.Image.Image.getexif
.. automethod:: PIL.Image.Image.getextrema
.. automethod:: PIL.Image.Image.getpalette

View File

@ -4,10 +4,50 @@
:py:mod:`~PIL.ImageMorph` module
================================
The :py:mod:`~PIL.ImageMorph` module provides morphology operations on images.
The :py:mod:`~PIL.ImageMorph` module allows `morphology`_ operators ("MorphOp") to be
applied to 1 or L mode images::
.. automodule:: PIL.ImageMorph
from PIL import Image, ImageMorph
img = Image.open("Tests/images/hopper.bw")
mop = ImageMorph.MorphOp(op_name="erosion4")
count, imgOut = mop.apply(img)
imgOut.show()
.. _morphology: https://en.wikipedia.org/wiki/Mathematical_morphology
In addition to applying operators, you can also analyse images.
You can inspect an image in isolation to determine which pixels are non-empty::
print(mop.get_on_pixels(img)) # [(0, 0), (1, 0), (2, 0), ...]
Or you can retrieve a list of pixels that match the operator. This is the number of
pixels that will be non-empty after the operator is applied::
coords = mop.match(img)
print(coords) # [(17, 1), (18, 1), (34, 1), ...]
print(len(coords)) # 550
imgOut = mop.apply(img)[1]
print(len(mop.get_on_pixels(imgOut))) # 550
If you would like more customized operators, you can pass patterns to the MorphOp
class::
mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"])
Or you can pass lookup table ("LUT") data directly. This LUT data can be constructed
with the :py:class:`~PIL.ImageMorph.LutBuilder`::
builder = ImageMorph.LutBuilder()
mop = ImageMorph.MorphOp(lut=builder.build_lut())
.. autoclass:: LutBuilder
:members:
:undoc-members:
:show-inheritance:
.. autoclass:: MorphOp
:members:
:undoc-members:
:show-inheritance:
:noindex:

View File

@ -1,70 +1,35 @@
12.1.0
------
Security
========
TODO
^^^^
TODO
:cve:`YYYY-XXXXX`: TODO
^^^^^^^^^^^^^^^^^^^^^^^
TODO
Backwards incompatible changes
==============================
TODO
^^^^
TODO
Deprecations
============
TODO
^^^^
Image getdata()
^^^^^^^^^^^^^^^
TODO
:py:meth:`~PIL.Image.Image.getdata` has been deprecated.
:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is
identical, except that it returns a tuple of pixel values, instead of an internal
Pillow data type.
API changes
===========
TODO
^^^^
ImageMorph build_default_lut()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TODO
To match the behaviour of :py:meth:`~PIL.ImageMorph.LutBuilder.build_lut`,
:py:meth:`~PIL.ImageMorph.LutBuilder.build_default_lut()` now returns the new LUT.
API additions
=============
ImageText.Text.wrap
^^^^^^^^^^^^^^^^^^^
Image get_flattened_data()
^^^^^^^^^^^^^^^^^^^^^^^^^^
:py:meth:`.ImageText.Text.wrap` has been added, to wrap text to fit within a given
width::
from PIL import ImageText
text = ImageText.Text("Hello World!")
text.wrap(50)
print(text.text) # "Hello\nWorld!"
or within a certain width and height, returning a new :py:class:`.ImageText.Text`
instance if the text does not fit::
text = ImageText.Text("Text does not fit within height")
print(text.wrap(50, 25).text == " within height")
print(text.text) # "Text does\nnot fit"
or scaling, optionally with a font size limit::
text.wrap(50, 15, "shrink")
text.wrap(50, 15, ("shrink", 7))
text.wrap(58, 10, "grow")
text.wrap(50, 50, ("grow", 12))
:py:meth:`~PIL.Image.Image.get_flattened_data` is identical to the deprecated
:py:meth:`~PIL.Image.Image.getdata`, except that the new method returns a tuple of
pixel values, instead of an internal Pillow data type.
Specify window in ImageGrab on macOS
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -78,7 +43,7 @@ macOS in addition to Windows. On macOS, this is a CGWindowID::
Other changes
=============
TODO
^^^^
Added MorphOp support for 1 mode images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TODO
:py:class:`~PIL.ImageMorph.MorphOp` now supports both 1 mode and L mode images.

View File

@ -0,0 +1,75 @@
12.2.0
------
Security
========
TODO
^^^^
TODO
:cve:`YYYY-XXXXX`: TODO
^^^^^^^^^^^^^^^^^^^^^^^
TODO
Backwards incompatible changes
==============================
TODO
^^^^
TODO
Deprecations
============
TODO
^^^^
TODO
API changes
===========
TODO
^^^^
TODO
API additions
=============
ImageText.Text.wrap
^^^^^^^^^^^^^^^^^^^
:py:meth:`.ImageText.Text.wrap` has been added, to wrap text to fit within a given
width::
from PIL import ImageText
text = ImageText.Text("Hello World!")
text.wrap(50)
print(text.text) # "Hello\nWorld!"
or within a certain width and height, returning a new :py:class:`.ImageText.Text`
instance if the text does not fit::
text = ImageText.Text("Text does not fit within height")
print(text.wrap(50, 25).text == " within height")
print(text.text) # "Text does\nnot fit"
or scaling, optionally with a font size limit::
text.wrap(50, 15, "shrink")
text.wrap(50, 15, ("shrink", 7))
text.wrap(58, 10, "grow")
text.wrap(50, 50, ("grow", 12))
Other changes
=============
TODO
^^^^
TODO

View File

@ -14,6 +14,8 @@ expected to be backported to earlier versions.
.. toctree::
:maxdepth: 2
versioning
12.2.0
12.1.0
12.0.0
11.3.0
@ -80,4 +82,3 @@ expected to be backported to earlier versions.
2.5.2
2.3.2
2.3.1
versioning

View File

@ -17,8 +17,8 @@ prior three months.
A quarterly release bumps the MAJOR version when incompatible API changes are
made, such as removing deprecated APIs or dropping an EOL Python version. In practice,
these occur every 12-18 months, guided by
`Python's EOL schedule <https://devguide.python.org/#status-of-python-branches>`_, and
these occur every October, guided by
`Python's EOL schedule <https://devguide.python.org/versions/>`__, and
any APIs that have been deprecated for at least a year are removed at the same time.
PATCH versions ("`Point Release <https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#user-content-point-release>`_"

View File

@ -77,6 +77,8 @@ class AvifImageFile(ImageFile.ImageFile):
):
msg = "Invalid opening codec"
raise ValueError(msg)
assert self.fp is not None
self._decoder = _avif.AvifDecoder(
self.fp.read(),
DECODE_CODEC_CHOICE,

View File

@ -258,6 +258,7 @@ class BlpImageFile(ImageFile.ImageFile):
format_description = "Blizzard Mipmap Format"
def _open(self) -> None:
assert self.fp is not None
self.magic = self.fp.read(4)
if not _accept(self.magic):
msg = f"Bad BLP magic {repr(self.magic)}"

View File

@ -76,6 +76,7 @@ class BmpImageFile(ImageFile.ImageFile):
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
"""Read relevant info about the BMP"""
assert self.fp is not None
read, seek = self.fp.read, self.fp.seek
if header:
seek(header)
@ -311,6 +312,7 @@ class BmpImageFile(ImageFile.ImageFile):
def _open(self) -> None:
"""Open file, check magic number and read header"""
# read 14 bytes: magic number, filesize, reserved, header final offset
assert self.fp is not None
head_data = self.fp.read(14)
# choke if the file does not have the required magic bytes
if not _accept(head_data):

View File

@ -41,6 +41,7 @@ class BufrStubImageFile(ImageFile.StubImageFile):
format_description = "BUFR"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "Not a BUFR file"
raise SyntaxError(msg)

View File

@ -45,6 +45,7 @@ class DcxImageFile(PcxImageFile):
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(4)
if not _accept(s):
msg = "not a DCX file"

View File

@ -189,6 +189,7 @@ class EpsImageFile(ImageFile.ImageFile):
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
def _open(self) -> None:
assert self.fp is not None
(length, offset) = self._find_offset(self.fp)
# go to offset - start of "%!PS"
@ -403,6 +404,7 @@ class EpsImageFile(ImageFile.ImageFile):
) -> Image.core.PixelAccess | None:
# Load EPS via Ghostscript
if self.tile:
assert self.fp is not None
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
self._mode = self.im.mode
self._size = self.im.size

View File

@ -58,6 +58,7 @@ class FpxImageFile(ImageFile.ImageFile):
# read the OLE directory and see if this is a likely
# to be a FlashPix file
assert self.fp is not None
try:
self.ole = olefile.OleFileIO(self.fp)
except OSError as e:
@ -229,6 +230,7 @@ class FpxImageFile(ImageFile.ImageFile):
if y >= ysize:
break # isn't really required
assert self.fp is not None
self.stream = stream
self._fp = self.fp
self.fp = None

View File

@ -72,6 +72,7 @@ class FtexImageFile(ImageFile.ImageFile):
format_description = "Texture File Format (IW2:EOC)"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not an FTEX file"
raise SyntaxError(msg)

View File

@ -42,6 +42,7 @@ class GbrImageFile(ImageFile.ImageFile):
format_description = "GIMP brush file"
def _open(self) -> None:
assert self.fp is not None
header_size = i32(self.fp.read(4))
if header_size < 20:
msg = "not a GIMP brush"
@ -88,6 +89,7 @@ class GbrImageFile(ImageFile.ImageFile):
def load(self) -> Image.core.PixelAccess | None:
if self._im is None:
assert self.fp is not None
self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self._data_size))
return Image.Image.load(self)

View File

@ -87,6 +87,7 @@ class GifImageFile(ImageFile.ImageFile):
global_palette = None
def data(self) -> bytes | None:
assert self.fp is not None
s = self.fp.read(1)
if s and s[0]:
return self.fp.read(s[0])
@ -100,6 +101,7 @@ class GifImageFile(ImageFile.ImageFile):
def _open(self) -> None:
# Screen
assert self.fp is not None
s = self.fp.read(13)
if not _accept(s):
msg = "not a GIF file"
@ -751,7 +753,7 @@ def _write_multiple_frames(
if delta.mode == "P":
# Convert to L without considering palette
delta_l = Image.new("L", delta.size)
delta_l.putdata(delta.getdata())
delta_l.putdata(delta.get_flattened_data())
delta = delta_l
mask = ImageMath.lambda_eval(
lambda args: args["convert"](args["im"] * 255, "1"),

View File

@ -41,6 +41,7 @@ class GribStubImageFile(ImageFile.StubImageFile):
format_description = "GRIB"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(8)):
msg = "Not a GRIB file"
raise SyntaxError(msg)

View File

@ -41,6 +41,7 @@ class HDF5StubImageFile(ImageFile.StubImageFile):
format_description = "HDF5"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(8)):
msg = "Not an HDF file"
raise SyntaxError(msg)

View File

@ -265,6 +265,7 @@ class IcnsImageFile(ImageFile.ImageFile):
format_description = "Mac OS icns resource"
def _open(self) -> None:
assert self.fp is not None
self.icns = IcnsFile(self.fp)
self._mode = "RGBA"
self.info["sizes"] = self.icns.itersizes()

View File

@ -340,6 +340,7 @@ class IcoImageFile(ImageFile.ImageFile):
format_description = "Windows Icon"
def _open(self) -> None:
assert self.fp is not None
self.ico = IcoFile(self.fp)
self.info["sizes"] = self.ico.sizes()
self.size = self.ico.entry[0].dim

View File

@ -125,6 +125,7 @@ class ImImageFile(ImageFile.ImageFile):
# Quick rejection: if there's not an LF among the first
# 100 bytes, this is (probably) not a text header.
assert self.fp is not None
if b"\n" not in self.fp.read(100):
msg = "not an IM file"
raise SyntaxError(msg)

View File

@ -590,16 +590,11 @@ class Image:
return new
# Context manager support
def __enter__(self):
def __enter__(self) -> Image:
return self
def __exit__(self, *args):
from . import ImageFile
if isinstance(self, ImageFile.ImageFile):
if getattr(self, "_exclusive_fp", False):
self._close_fp()
self.fp = None
def __exit__(self, *args: object) -> None:
pass
def close(self) -> None:
"""
@ -1440,12 +1435,31 @@ class Image:
value (e.g. 0 to get the "R" band from an "RGB" image).
:returns: A sequence-like object.
"""
deprecate("Image.Image.getdata", 14, "get_flattened_data")
self.load()
if band is not None:
return self.im.getband(band)
return self.im # could be abused
def get_flattened_data(
self, band: int | None = None
) -> tuple[tuple[int, ...], ...] | tuple[float, ...]:
"""
Returns the contents of this image as a tuple containing pixel values.
The sequence object is flattened, so that values for line one follow
directly after the values of line zero, and so on.
:param band: What band to return. The default is to return
all bands. To return a single band, pass in the index
value (e.g. 0 to get the "R" band from an "RGB" image).
:returns: A tuple containing pixel values.
"""
self.load()
if band is not None:
return tuple(self.im.getband(band))
return tuple(self.im)
def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]:
"""
Gets the minimum and maximum pixel values for each band in
@ -1524,6 +1538,8 @@ class Image:
assert isinstance(self, TiffImagePlugin.TiffImageFile)
self._exif.bigtiff = self.tag_v2._bigtiff
self._exif.endian = self.tag_v2._endian
assert self.fp is not None
self._exif.load_from_fp(self.fp, self.tag_v2._offset)
if exif_info is not None:
self._exif.load(exif_info)

View File

@ -131,6 +131,8 @@ class ImageFile(Image.Image):
self.decoderconfig: tuple[Any, ...] = ()
self.decodermaxblock = MAXBLOCK
self.fp: IO[bytes] | None
self._fp: IO[bytes] | DeferredError
if is_path(fp):
# filename
self.fp = open(fp, "rb")
@ -167,7 +169,11 @@ class ImageFile(Image.Image):
def _open(self) -> None:
pass
def _close_fp(self):
# Context manager support
def __enter__(self) -> ImageFile:
return self
def _close_fp(self) -> None:
if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError):
if self._fp != self.fp:
self._fp.close()
@ -175,6 +181,11 @@ class ImageFile(Image.Image):
if self.fp:
self.fp.close()
def __exit__(self, *args: object) -> None:
if getattr(self, "_exclusive_fp", False):
self._close_fp()
self.fp = None
def close(self) -> None:
"""
Closes the file pointer, if possible.
@ -267,7 +278,7 @@ class ImageFile(Image.Image):
# raise exception if something's wrong. must be called
# directly after open, and closes file when finished.
if self._exclusive_fp:
if self._exclusive_fp and self.fp:
self.fp.close()
self.fp = None
@ -285,6 +296,7 @@ class ImageFile(Image.Image):
self.map: mmap.mmap | None = None
use_mmap = self.filename and len(self.tile) == 1
assert self.fp is not None
readonly = 0
# look for read/seek overrides

View File

@ -675,8 +675,12 @@ class FreeTypeFont:
:returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font.
"""
names = self.font.getvarnames()
return [name.replace(b"\x00", b"") for name in names]
names = []
for name in self.font.getvarnames():
name = name.replace(b"\x00", b"")
if name not in names:
names.append(name)
return names
def set_variation_by_name(self, name: str | bytes) -> None:
"""

View File

@ -65,10 +65,12 @@ class LutBuilder:
def __init__(
self, patterns: list[str] | None = None, op_name: str | None = None
) -> None:
if patterns is not None:
self.patterns = patterns
else:
self.patterns = []
"""
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8" or "edge".
:exception Exception: If the op_name is not recognized.
"""
self.lut: bytearray | None = None
if op_name is not None:
known_patterns = {
@ -88,20 +90,38 @@ class LutBuilder:
raise Exception(msg)
self.patterns = known_patterns[op_name]
elif patterns is not None:
self.patterns = patterns
else:
self.patterns = []
def add_patterns(self, patterns: list[str]) -> None:
"""
Append to list of patterns.
:param patterns: Additional patterns.
"""
self.patterns += patterns
def build_default_lut(self) -> None:
def build_default_lut(self) -> bytearray:
"""
Set the current LUT, and return it.
This is the default LUT that patterns will be applied against when building.
"""
symbols = [0, 1]
m = 1 << 4 # pos of current pixel
self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))
return self.lut
def get_lut(self) -> bytearray | None:
"""
Returns the current LUT
"""
return self.lut
def _string_permute(self, pattern: str, permutation: list[int]) -> str:
"""string_permute takes a pattern and a permutation and returns the
"""Takes a pattern and a permutation and returns the
string permuted according to the permutation list.
"""
assert len(permutation) == 9
@ -110,7 +130,7 @@ class LutBuilder:
def _pattern_permute(
self, basic_pattern: str, options: str, basic_result: int
) -> list[tuple[str, int]]:
"""pattern_permute takes a basic pattern and its result and clones
"""Takes a basic pattern and its result and clones
the pattern according to the modifications described in the $options
parameter. It returns a list of all cloned patterns."""
patterns = [(basic_pattern, basic_result)]
@ -140,10 +160,9 @@ class LutBuilder:
return patterns
def build_lut(self) -> bytearray:
"""Compile all patterns into a morphology lut.
"""Compile all patterns into a morphology LUT, and return it.
TBD :Build based on (file) morphlut:modify_lut
"""
This is the data to be passed into MorphOp."""
self.build_default_lut()
assert self.lut is not None
patterns = []
@ -163,15 +182,14 @@ class LutBuilder:
patterns += self._pattern_permute(pattern, options, result)
# compile the patterns into regular expressions for speed
# Compile the patterns into regular expressions for speed
compiled_patterns = []
for pattern in patterns:
p = pattern[0].replace(".", "X").replace("X", "[01]")
compiled_patterns.append((re.compile(p), pattern[1]))
# Step through table and find patterns that match.
# Note that all the patterns are searched. The last one
# caught overrides
# Note that all the patterns are searched. The last one found takes priority
for i in range(LUT_SIZE):
# Build the bit pattern
bitpattern = bin(i)[2:]
@ -193,26 +211,39 @@ class MorphOp:
op_name: str | None = None,
patterns: list[str] | None = None,
) -> None:
"""Create a binary morphological operator"""
self.lut = lut
if op_name is not None:
self.lut = LutBuilder(op_name=op_name).build_lut()
elif patterns is not None:
self.lut = LutBuilder(patterns=patterns).build_lut()
"""Create a binary morphological operator.
If the LUT is not provided, then it is built using LutBuilder from the op_name
or the patterns.
:param lut: The LUT data.
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8", "edge".
:exception Exception: If the op_name is not recognized.
"""
if patterns is None and op_name is None:
self.lut = lut
else:
self.lut = LutBuilder(patterns, op_name).build_lut()
def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
"""Run a single morphological operation on an image
"""Run a single morphological operation on an image.
Returns a tuple of the number of changed pixels and the
morphed image"""
morphed image.
:param image: A 1-mode or L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not 1 or L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
if image.mode != "L":
msg = "Image mode must be L"
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
outimage = Image.new(image.mode, image.size, None)
outimage = Image.new(image.mode, image.size)
count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim())
return count, outimage
@ -220,30 +251,42 @@ class MorphOp:
"""Get a list of coordinates matching the morphological operation on
an image.
Returns a list of tuples of (x,y) coordinates
of all matching pixels. See :ref:`coordinate-system`."""
Returns a list of tuples of (x,y) coordinates of all matching pixels. See
:ref:`coordinate-system`.
:param image: A 1-mode or L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not 1 or L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
if image.mode != "L":
msg = "Image mode must be L"
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
return _imagingmorph.match(bytes(self.lut), image.getim())
def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of all turned on pixels in a binary image
"""Get a list of all turned on pixels in a 1 or L mode image.
Returns a list of tuples of (x,y) coordinates
of all matching pixels. See :ref:`coordinate-system`."""
Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See
:ref:`coordinate-system`.
if image.mode != "L":
msg = "Image mode must be L"
:param image: A 1-mode or L-mode image.
:exception ValueError: If the image is not 1 or L mode."""
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
return _imagingmorph.get_on_pixels(image.getim())
def load_lut(self, filename: str) -> None:
"""Load an operator from an mrl file"""
"""
Load an operator from an mrl file
:param filename: The file to read from.
:exception Exception: If the length of the file data is not 512.
"""
with open(filename, "rb") as f:
self.lut = bytearray(f.read())
@ -253,7 +296,12 @@ class MorphOp:
raise Exception(msg)
def save_lut(self, filename: str) -> None:
"""Save an operator to an mrl file"""
"""
Save an operator to an mrl file.
:param filename: The destination file.
:exception Exception: If the current operator is None.
"""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
@ -261,5 +309,9 @@ class MorphOp:
f.write(self.lut)
def set_lut(self, lut: bytearray | None) -> None:
"""Set the lut from an external source"""
"""
Set the LUT from an external source
:param lut: A new LUT.
"""
self.lut = lut

View File

@ -49,6 +49,7 @@ class IptcImageFile(ImageFile.ImageFile):
def field(self) -> tuple[tuple[int, int] | None, int]:
#
# get a IPTC field header
assert self.fp is not None
s = self.fp.read(5)
if not s.strip(b"\x00"):
return None, 0
@ -76,6 +77,7 @@ class IptcImageFile(ImageFile.ImageFile):
def _open(self) -> None:
# load descriptive fields
assert self.fp is not None
while True:
offset = self.fp.tell()
tag, size = self.field()
@ -131,6 +133,7 @@ class IptcImageFile(ImageFile.ImageFile):
assert isinstance(args, tuple)
compression, band = args
assert self.fp is not None
self.fp.seek(self.tile[0].offset)
# Copy image data to temporary file
@ -154,10 +157,11 @@ class IptcImageFile(ImageFile.ImageFile):
if band is not None:
bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
bands[band] = _im
_im = Image.merge(self.mode, bands)
im = Image.merge(self.mode, bands)
else:
_im.load()
self.im = _im.im
im = _im
im.load()
self.im = im.im
self.tile = []
return ImageFile.ImageFile.load(self)

View File

@ -252,6 +252,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
format_description = "JPEG 2000 (ISO 15444)"
def _open(self) -> None:
assert self.fp is not None
sig = self.fp.read(4)
if sig == b"\xff\x4f\xff\x51":
self.codec = "j2k"
@ -304,6 +305,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
]
def _parse_comment(self) -> None:
assert self.fp is not None
while True:
marker = self.fp.read(2)
if not marker:

View File

@ -61,6 +61,7 @@ if TYPE_CHECKING:
def Skip(self: JpegImageFile, marker: int) -> None:
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
ImageFile._safe_read(self.fp, n)
@ -70,6 +71,7 @@ def APP(self: JpegImageFile, marker: int) -> None:
# Application marker. Store these in the APP dictionary.
# Also look for well-known application markers.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
@ -174,6 +176,7 @@ def APP(self: JpegImageFile, marker: int) -> None:
def COM(self: JpegImageFile, marker: int) -> None:
#
# Comment marker. Store these in the APP dictionary.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
@ -190,6 +193,7 @@ def SOF(self: JpegImageFile, marker: int) -> None:
# mode. Note that this could be made a bit brighter, by
# looking for JFIF and Adobe APP markers.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
self._size = i16(s, 3), i16(s, 1)
@ -240,6 +244,7 @@ def DQT(self: JpegImageFile, marker: int) -> None:
# FIXME: The quantization tables can be used to estimate the
# compression quality.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
while len(s):
@ -340,6 +345,7 @@ class JpegImageFile(ImageFile.ImageFile):
format_description = "JPEG (ISO 10918)"
def _open(self) -> None:
assert self.fp is not None
s = self.fp.read(3)
if not _accept(s):
@ -408,6 +414,7 @@ class JpegImageFile(ImageFile.ImageFile):
For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker
so libjpeg can finish decoding
"""
assert self.fp is not None
s = self.fp.read(read_bytes)
if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"):

View File

@ -67,6 +67,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
self._n_frames = len(self.images)
self.is_animated = self._n_frames > 1
assert self.fp is not None
self.__fp = self.fp
self.seek(0)

View File

@ -106,6 +106,7 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
assert self.fp is not None
self.fp.seek(0) # prep the fp in order to pass the JPEG test
JpegImagePlugin.JpegImageFile._open(self)
self._after_jpeg_open()
@ -125,6 +126,7 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
assert self.n_frames == len(self.__mpoffsets)
del self.info["mpoffset"] # no longer needed
self.is_animated = self.n_frames > 1
assert self.fp is not None
self._fp = self.fp # FIXME: hack
self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame
self.__frame = 0

View File

@ -39,6 +39,7 @@ import struct
import warnings
import zlib
from enum import IntEnum
from fractions import Fraction
from typing import IO, NamedTuple, cast
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
@ -759,6 +760,7 @@ class PngImageFile(ImageFile.ImageFile):
format_description = "Portable network graphics"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(8)):
msg = "not a PNG file"
raise SyntaxError(msg)
@ -855,9 +857,7 @@ class PngImageFile(ImageFile.ImageFile):
self.png.verify()
self.png.close()
if self._exclusive_fp:
self.fp.close()
self.fp = None
super().verify()
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
@ -990,6 +990,7 @@ class PngImageFile(ImageFile.ImageFile):
"""internal: read more image data"""
assert self.png is not None
assert self.fp is not None
while self.__idat == 0:
# end of chunk, skip forward to next one
@ -1023,6 +1024,7 @@ class PngImageFile(ImageFile.ImageFile):
def load_end(self) -> None:
"""internal: finished reading image data"""
assert self.png is not None
assert self.fp is not None
if self.__idat != 0:
self.fp.read(self.__idat)
while True:
@ -1274,7 +1276,11 @@ def _write_multiple_frames(
im_frame = im_frame.crop(bbox)
size = im_frame.size
encoderinfo = frame_data.encoderinfo
frame_duration = int(round(encoderinfo.get("duration", 0)))
frame_duration = encoderinfo.get("duration", 0)
delay = Fraction(frame_duration / 1000).limit_denominator(65535)
if delay.numerator > 65535:
msg = "cannot write duration"
raise ValueError(msg)
frame_disposal = encoderinfo.get("disposal", disposal)
frame_blend = encoderinfo.get("blend", blend)
# frame control
@ -1286,8 +1292,8 @@ def _write_multiple_frames(
o32(size[1]), # height
o32(bbox[0]), # x_offset
o32(bbox[1]), # y_offset
o16(frame_duration), # delay_numerator
o16(1000), # delay_denominator
o16(delay.numerator), # delay_numerator
o16(delay.denominator), # delay_denominator
o8(frame_disposal), # dispose_op
o8(frame_blend), # blend_op
)

View File

@ -61,6 +61,7 @@ class PsdImageFile(ImageFile.ImageFile):
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
assert self.fp is not None
read = self.fp.read
#

View File

@ -25,6 +25,7 @@ class QoiImageFile(ImageFile.ImageFile):
format_description = "Quite OK Image"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not a QOI file"
raise SyntaxError(msg)

View File

@ -104,6 +104,7 @@ class SpiderImageFile(ImageFile.ImageFile):
def _open(self) -> None:
# check header
n = 27 * 4 # read 27 float values
assert self.fp is not None
f = self.fp.read(n)
try:
@ -323,9 +324,9 @@ if __name__ == "__main__":
outfile = sys.argv[2]
# perform some image operation
im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
transposed_im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
print(
f"saving a flipped version of {os.path.basename(filename)} "
f"as {outfile} "
)
im.save(outfile, SpiderImageFile.format)
transposed_im.save(outfile, SpiderImageFile.format)

View File

@ -39,6 +39,7 @@ class WalImageFile(ImageFile.ImageFile):
self._mode = "P"
# read header fields
assert self.fp is not None
header = self.fp.read(32 + 24 + 32 + 12)
self._size = i32(header, 32), i32(header, 36)
Image._decompression_bomb_check(self.size)
@ -54,6 +55,7 @@ class WalImageFile(ImageFile.ImageFile):
def load(self) -> Image.core.PixelAccess | None:
if self._im is None:
assert self.fp is not None
self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self.size[0] * self.size[1]))
self.putpalette(quake2palette)

View File

@ -45,33 +45,30 @@ class WebPImageFile(ImageFile.ImageFile):
def _open(self) -> None:
# Use the newer AnimDecoder API to parse the (possibly) animated file,
# and access muxed chunks like ICC/EXIF/XMP.
assert self.fp is not None
self._decoder = _webp.WebPAnimDecoder(self.fp.read())
# Get info from decoder
self._size, loop_count, bgcolor, frame_count, mode = self._decoder.get_info()
self.info["loop"] = loop_count
bg_a, bg_r, bg_g, bg_b = (
(bgcolor >> 24) & 0xFF,
(bgcolor >> 16) & 0xFF,
(bgcolor >> 8) & 0xFF,
bgcolor & 0xFF,
self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = (
self._decoder.get_info()
)
self.info["background"] = (
(bgcolor >> 16) & 0xFF, # R
(bgcolor >> 8) & 0xFF, # G
bgcolor & 0xFF, # B
(bgcolor >> 24) & 0xFF, # A
)
self.info["background"] = (bg_r, bg_g, bg_b, bg_a)
self.n_frames = frame_count
self.is_animated = self.n_frames > 1
self._mode = "RGB" if mode == "RGBX" else mode
self.rawmode = mode
self._mode = "RGB" if self.rawmode == "RGBX" else self.rawmode
# Attempt to read ICC / EXIF / XMP chunks from file
icc_profile = self._decoder.get_chunk("ICCP")
exif = self._decoder.get_chunk("EXIF")
xmp = self._decoder.get_chunk("XMP ")
if icc_profile:
self.info["icc_profile"] = icc_profile
if exif:
self.info["exif"] = exif
if xmp:
self.info["xmp"] = xmp
for key, chunk_name in {
"icc_profile": "ICCP",
"exif": "EXIF",
"xmp": "XMP ",
}.items():
if value := self._decoder.get_chunk(chunk_name):
self.info[key] = value
# Initialize seek state
self._reset(reset=False)
@ -129,9 +126,7 @@ class WebPImageFile(ImageFile.ImageFile):
self._seek(self.__logical_frame)
# We need to load the image data for this frame
data, timestamp, duration = self._get_next()
self.info["timestamp"] = timestamp
self.info["duration"] = duration
data, self.info["timestamp"], self.info["duration"] = self._get_next()
self.__loaded = self.__logical_frame
# Set tile

View File

@ -49,6 +49,7 @@ if hasattr(Image.core, "drawwmf"):
self.bbox = im.info["wmf_bbox"]
def load(self, im: ImageFile.StubImageFile) -> Image.Image:
assert im.fp is not None
im.fp.seek(0) # rewind
return Image.frombytes(
"RGB",
@ -81,6 +82,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
def _open(self) -> None:
# check placeable header
assert self.fp is not None
s = self.fp.read(44)
if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"):

View File

@ -48,6 +48,8 @@ def deprecate(
raise RuntimeError(msg)
elif when == 13:
removed = "Pillow 13 (2026-10-15)"
elif when == 14:
removed = "Pillow 14 (2027-10-15)"
else:
msg = f"Unknown removal version: {when}. Update {__name__}?"
raise ValueError(msg)

View File

@ -1,4 +1,4 @@
# Master version for Pillow
from __future__ import annotations
__version__ = "12.1.0.dev0"
__version__ = "12.2.0.dev0"

View File

@ -2459,7 +2459,6 @@ _merge(PyObject *self, PyObject *args) {
static PyObject *
_split(ImagingObject *self) {
int fails = 0;
Py_ssize_t i;
PyObject *list;
PyObject *imaging_object;
@ -2473,14 +2472,12 @@ _split(ImagingObject *self) {
for (i = 0; i < self->image->bands; i++) {
imaging_object = PyImagingNew(bands[i]);
if (!imaging_object) {
fails += 1;
Py_DECREF(list);
list = NULL;
break;
}
PyTuple_SET_ITEM(list, i, imaging_object);
}
if (fails) {
Py_DECREF(list);
list = NULL;
}
return list;
}

View File

@ -1287,7 +1287,6 @@ font_getvarnames(FontObject *self) {
}
PyList_SetItem(list_names, j, list_name);
list_names_filled[j] = 1;
break;
}
}
}

View File

@ -663,7 +663,7 @@ half_to_float(UINT16 h) {
if (o.f >= m.f) {
o.u |= 255 << 23;
}
o.u |= (h & 0x8000) << 16;
o.u |= (UINT32)(h & 0x8000) << 16;
return o.f;
}

View File

@ -18,9 +18,9 @@
#define I16(ptr) ((ptr)[0] + ((int)(ptr)[1] << 8))
#define I32(ptr) \
((ptr)[0] + ((INT32)(ptr)[1] << 8) + ((INT32)(ptr)[2] << 16) + \
((INT32)(ptr)[3] << 24))
#define I32(ptr) \
((ptr)[0] + ((unsigned long)(ptr)[1] << 8) + ((unsigned long)(ptr)[2] << 16) + \
((unsigned long)(ptr)[3] << 24))
#define ERR_IF_DATA_OOB(offset) \
if ((data + (offset)) > ptr + bytes) { \
@ -31,8 +31,8 @@
int
ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t bytes) {
UINT8 *ptr;
int framesize;
int c, chunks, advance;
unsigned long framesize, advance;
int c, chunks;
int l, lines;
int i, j, x = 0, y, ymax;

View File

@ -3,6 +3,7 @@ requires =
tox>=4.2
env_list =
lint
mypy
py{py3, 315, 314, 313, 312, 311, 310}
[testenv]
@ -18,11 +19,11 @@ commands =
skip_install = true
deps =
check-manifest
pre-commit
prek
pass_env =
PRE_COMMIT_COLOR
PREK_COLOR
commands =
pre-commit run --all-files --show-diff-on-failure
prek run --all-files --show-diff-on-failure
check-manifest
[testenv:mypy]

View File

@ -11,7 +11,8 @@ For more extensive info, see the [Windows build instructions](build.rst).
* Requires Microsoft Visual Studio 2017 or newer with C++ component.
* Requires NASM for libjpeg-turbo, a required dependency when using this script.
* Requires CMake 3.15 or newer (available as Visual Studio component).
* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise (GitHub Actions).
* Tested on Windows Server 2025 and 2022 with Visual Studio 2022 Enterprise (GitHub
Actions).
Here's an example script to build on Windows:

View File

@ -116,8 +116,8 @@ V = {
"BROTLI": "1.2.0",
"FREETYPE": "2.14.1",
"FRIBIDI": "1.0.16",
"HARFBUZZ": "12.2.0",
"JPEGTURBO": "3.1.2",
"HARFBUZZ": "12.3.0",
"JPEGTURBO": "3.1.3",
"LCMS2": "2.17",
"LIBAVIF": "1.3.0",
"LIBIMAGEQUANT": "4.4.1",
@ -125,8 +125,8 @@ V = {
"LIBWEBP": "1.6.0",
"OPENJPEG": "2.5.4",
"TIFF": "4.7.1",
"XZ": "5.8.1",
"ZLIBNG": "2.3.1",
"XZ": "5.8.2",
"ZLIBNG": "2.3.2",
}
V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2])
@ -183,11 +183,7 @@ DEPS: dict[str, dict[str, Any]] = {
"filename": f"xz-{V['XZ']}.tar.gz",
"license": "COPYING",
"build": [
*cmds_cmake(
"liblzma",
"-DBUILD_SHARED_LIBS:BOOL=OFF"
+ (" -DXZ_CLMUL_CRC:BOOL=OFF" if struct.calcsize("P") == 4 else ""),
),
*cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"),
cmd_mkdir(r"{inc_dir}\lzma"),
cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"),
],