Merge branch 'main' into has_characters

This commit is contained in:
Andrew Murray 2025-08-26 18:51:40 +10:00 committed by GitHub
commit 4bc6dedcf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 91 additions and 110 deletions

View File

@ -1 +1 @@
cibuildwheel==3.1.3 cibuildwheel==3.1.4

View File

@ -32,7 +32,7 @@ jobs:
name: Docs name: Docs
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -20,7 +20,7 @@ jobs:
name: Lint name: Lint
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -68,7 +68,7 @@ jobs:
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -45,7 +45,7 @@ jobs:
steps: steps:
- name: Checkout Pillow - name: Checkout Pillow
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -41,7 +41,7 @@ jobs:
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -39,7 +39,7 @@ jobs:
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -47,19 +47,19 @@ jobs:
steps: steps:
- name: Checkout Pillow - name: Checkout Pillow
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Checkout cached dependencies - name: Checkout cached dependencies
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
repository: python-pillow/pillow-depends repository: python-pillow/pillow-depends
path: winbuild\depends path: winbuild\depends
- name: Checkout extra test images - name: Checkout extra test images
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
repository: python-pillow/test-images repository: python-pillow/test-images

View File

@ -65,7 +65,7 @@ jobs:
name: ${{ matrix.os }} Python ${{ matrix.python-version }} name: ${{ matrix.os }} Python ${{ matrix.python-version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -99,14 +99,14 @@ jobs:
cibw_arch: arm64_iphoneos cibw_arch: arm64_iphoneos
- name: "iOS arm64 simulator" - name: "iOS arm64 simulator"
platform: ios platform: ios
os: macos-latest os: macos-14
cibw_arch: arm64_iphonesimulator cibw_arch: arm64_iphonesimulator
- name: "iOS x86_64 simulator" - name: "iOS x86_64 simulator"
platform: ios platform: ios
os: macos-13 os: macos-13
cibw_arch: x86_64_iphonesimulator cibw_arch: x86_64_iphonesimulator
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
submodules: true submodules: true
@ -153,12 +153,12 @@ jobs:
- cibw_arch: ARM64 - cibw_arch: ARM64
os: windows-11-arm os: windows-11-arm
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Checkout extra test images - name: Checkout extra test images
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
repository: python-pillow/test-images repository: python-pillow/test-images
@ -234,7 +234,7 @@ jobs:
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -175,6 +175,14 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
return pytest.mark.skipif(not features.check(feature), reason=reason) return pytest.mark.skipif(not features.check(feature), reason=reason)
def has_feature_version(feature: str, required: str) -> bool:
version = features.version(feature)
assert version is not None
version_required = parse_version(required)
version_available = parse_version(version)
return version_available >= version_required
def skip_unless_feature_version( def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:

View File

@ -4,13 +4,13 @@ from collections.abc import Generator
from pathlib import Path from pathlib import Path
import pytest import pytest
from packaging.version import parse as parse_version
from PIL import GifImagePlugin, Image, WebPImagePlugin, features from PIL import GifImagePlugin, Image, WebPImagePlugin
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
assert_image_similar, assert_image_similar,
has_feature_version,
is_big_endian, is_big_endian,
skip_unless_feature, skip_unless_feature,
) )
@ -53,11 +53,8 @@ def test_write_animation_L(tmp_path: Path) -> None:
im.load() im.load()
assert_image_similar(im, orig.convert("RGBA"), 32.9) assert_image_similar(im, orig.convert("RGBA"), 32.9)
if is_big_endian(): if is_big_endian() and not has_feature_version("webp", "1.2.2"):
version = features.version_module("webp") pytest.skip("Fails with libwebp earlier than 1.2.2")
assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1) orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
orig.load() orig.load()
@ -81,11 +78,8 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
assert_image_equal(im, frame1.convert("RGBA")) assert_image_equal(im, frame1.convert("RGBA"))
# Compare second frame to original # Compare second frame to original
if is_big_endian(): if is_big_endian() and not has_feature_version("webp", "1.2.2"):
version = features.version_module("webp") pytest.skip("Fails with libwebp earlier than 1.2.2")
assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1) im.seek(1)
im.load() im.load()
assert_image_equal(im, frame2.convert("RGBA")) assert_image_equal(im, frame2.convert("RGBA"))

View File

@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from packaging.version import parse as parse_version
from PIL import Image, features from PIL import Image
from .helper import assert_image_similar, hopper, is_ppc64le, skip_unless_feature from .helper import (
assert_image_similar,
has_feature_version,
hopper,
is_ppc64le,
skip_unless_feature,
)
def test_sanity() -> None: def test_sanity() -> None:
@ -23,11 +28,8 @@ def test_sanity() -> None:
@skip_unless_feature("libimagequant") @skip_unless_feature("libimagequant")
def test_libimagequant_quantize() -> None: def test_libimagequant_quantize() -> None:
image = hopper() image = hopper()
if is_ppc64le(): if is_ppc64le() and not has_feature_version("libimagequant", "4"):
version = features.version_feature("libimagequant") pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
assert version is not None
if parse_version(version) < parse_version("4"):
pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P" assert converted.mode == "P"
assert_image_similar(converted.convert("RGB"), image, 15) assert_image_similar(converted.convert("RGB"), image, 15)

View File

@ -7,6 +7,7 @@ from PIL import Image, ImageDraw, ImageFont
from .helper import ( from .helper import (
assert_image_equal_tofile, assert_image_equal_tofile,
assert_image_similar_tofile, assert_image_similar_tofile,
has_feature_version,
skip_unless_feature, skip_unless_feature,
) )
@ -104,11 +105,9 @@ def test_text_direction_ttb() -> None:
im = Image.new(mode="RGB", size=(100, 300)) im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
try: if not has_feature_version("raqm", "0.7"):
draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb") pytest.skip("libraqm 0.7 or greater not available")
except ValueError as ex: draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available")
target = "Tests/images/test_direction_ttb.png" target = "Tests/images/test_direction_ttb.png"
assert_image_similar_tofile(im, target, 2.8) assert_image_similar_tofile(im, target, 2.8)
@ -119,19 +118,17 @@ def test_text_direction_ttb_stroke() -> None:
im = Image.new(mode="RGB", size=(100, 300)) im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
try: if not has_feature_version("raqm", "0.7"):
draw.text( pytest.skip("libraqm 0.7 or greater not available")
(27, 27), draw.text(
"あい", (27, 27),
font=ttf, "あい",
fill=500, font=ttf,
direction="ttb", fill=500,
stroke_width=2, direction="ttb",
stroke_fill="#0f0", stroke_width=2,
) stroke_fill="#0f0",
except ValueError as ex: )
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available")
target = "Tests/images/test_direction_ttb_stroke.png" target = "Tests/images/test_direction_ttb_stroke.png"
assert_image_similar_tofile(im, target, 19.4) assert_image_similar_tofile(im, target, 19.4)
@ -219,14 +216,9 @@ def test_getlength(
im = Image.new(mode, (1, 1), 0) im = Image.new(mode, (1, 1), 0)
d = ImageDraw.Draw(im) d = ImageDraw.Draw(im)
try: if direction == "ttb" and not has_feature_version("raqm", "0.7"):
assert d.textlength(text, ttf, direction) == expected pytest.skip("libraqm 0.7 or greater not available")
except ValueError as ex: assert d.textlength(text, ttf, direction) == expected
if (
direction == "ttb"
and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
):
pytest.skip("libraqm 0.7 or greater not available")
@pytest.mark.parametrize("mode", ("L", "1")) @pytest.mark.parametrize("mode", ("L", "1"))
@ -242,17 +234,12 @@ def test_getlength_combine(mode: str, direction: str, text: str) -> None:
ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
try: if direction == "ttb" and not has_feature_version("raqm", "0.7"):
target = ttf.getlength("ii", mode, direction) pytest.skip("libraqm 0.7 or greater not available")
actual = ttf.getlength(text, mode, direction) target = ttf.getlength("ii", mode, direction)
actual = ttf.getlength(text, mode, direction)
assert actual == target assert actual == target
except ValueError as ex:
if (
direction == "ttb"
and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
):
pytest.skip("libraqm 0.7 or greater not available")
@pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm")) @pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm"))
@ -265,11 +252,9 @@ def test_anchor_ttb(anchor: str) -> None:
d = ImageDraw.Draw(im) d = ImageDraw.Draw(im)
d.line(((0, 200), (200, 200)), "gray") d.line(((0, 200), (200, 200)), "gray")
d.line(((100, 0), (100, 400)), "gray") d.line(((100, 0), (100, 400)), "gray")
try: if not has_feature_version("raqm", "0.7"):
d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f) pytest.skip("libraqm 0.7 or greater not available")
except ValueError as ex: d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available")
assert_image_similar_tofile(im, path, 1) # fails at 5 assert_image_similar_tofile(im, path, 1) # fails at 5
@ -310,10 +295,12 @@ combine_tests = (
# this tests various combining characters for anchor alignment and clipping # this tests various combining characters for anchor alignment and clipping
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests] "name, text, anchor, direction, epsilon",
combine_tests,
ids=[r[0] for r in combine_tests],
) )
def test_combine( def test_combine(
name: str, text: str, dir: str | None, anchor: str | None, epsilon: float name: str, text: str, direction: str | None, anchor: str | None, epsilon: float
) -> None: ) -> None:
path = f"Tests/images/test_combine_{name}.png" path = f"Tests/images/test_combine_{name}.png"
f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
@ -322,11 +309,9 @@ def test_combine(
d = ImageDraw.Draw(im) d = ImageDraw.Draw(im)
d.line(((0, 200), (400, 200)), "gray") d.line(((0, 200), (400, 200)), "gray")
d.line(((200, 0), (200, 400)), "gray") d.line(((200, 0), (200, 400)), "gray")
try: if direction == "ttb" and not has_feature_version("raqm", "0.7"):
d.text((200, 200), text, fill="black", anchor=anchor, direction=dir, font=f) pytest.skip("libraqm 0.7 or greater not available")
except ValueError as ex: d.text((200, 200), text, fill="black", anchor=anchor, direction=direction, font=f)
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available")
assert_image_similar_tofile(im, path, epsilon) assert_image_similar_tofile(im, path, epsilon)

View File

@ -28,15 +28,13 @@ def test_numpy_to_image() -> None:
a = numpy.array(data, dtype=dtype) a = numpy.array(data, dtype=dtype)
a.shape = TEST_IMAGE_SIZE a.shape = TEST_IMAGE_SIZE
i = Image.fromarray(a) i = Image.fromarray(a)
if list(i.getdata()) != data: assert list(i.getdata()) == data
print("data mismatch for", dtype)
else: else:
data = list(range(100)) data = list(range(100))
a = numpy.array([[x] * bands for x in data], dtype=dtype) a = numpy.array([[x] * bands for x in data], dtype=dtype)
a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands
i = Image.fromarray(a) i = Image.fromarray(a)
if list(i.getchannel(0).getdata()) != list(range(100)): assert list(i.getchannel(0).getdata()) == list(range(100))
print("data mismatch for", dtype)
return i return i
# Check supported 1-bit integer formats # Check supported 1-bit integer formats

View File

@ -74,5 +74,6 @@ Constants
--------- ---------
.. autodata:: PIL.ImageFile.LOAD_TRUNCATED_IMAGES .. autodata:: PIL.ImageFile.LOAD_TRUNCATED_IMAGES
.. autodata:: PIL.ImageFile.MAXBLOCK
.. autodata:: PIL.ImageFile.ERRORS .. autodata:: PIL.ImageFile.ERRORS
:annotation: :annotation:

View File

@ -103,7 +103,6 @@ try:
raise ImportError(msg) raise ImportError(msg)
except ImportError as v: except ImportError as v:
core = DeferredError.new(ImportError("The _imaging C module is not installed."))
# Explanations for ways that we know we might have an import error # Explanations for ways that we know we might have an import error
if str(v).startswith("Module use of python"): if str(v).startswith("Module use of python"):
# The _imaging C module is present, but not compiled for # The _imaging C module is present, but not compiled for

View File

@ -46,6 +46,18 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAXBLOCK = 65536 MAXBLOCK = 65536
"""
By default, Pillow processes image data in blocks. This helps to prevent excessive use
of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
When reading an image, this is the number of bytes to read at once.
When writing an image, this is the number of bytes to write at once.
If the image width times 4 is greater, then that will be used instead.
Plugins may also set a greater number.
User code may set this to another number.
"""
SAFEBLOCK = 1024 * 1024 SAFEBLOCK = 1024 * 1024

View File

@ -681,11 +681,7 @@ class FreeTypeFont:
:returns: A list of the named styles in a variation font. :returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.
""" """
try: names = self.font.getvarnames()
names = self.font.getvarnames()
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
return [name.replace(b"\x00", b"") for name in names] return [name.replace(b"\x00", b"") for name in names]
def set_variation_by_name(self, name: str | bytes) -> None: def set_variation_by_name(self, name: str | bytes) -> None:
@ -712,11 +708,7 @@ class FreeTypeFont:
:returns: A list of the axes in a variation font. :returns: A list of the axes in a variation font.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.
""" """
try: axes = self.font.getvaraxes()
axes = self.font.getvaraxes()
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
for axis in axes: for axis in axes:
if axis["name"]: if axis["name"]:
axis["name"] = axis["name"].replace(b"\x00", b"") axis["name"] = axis["name"].replace(b"\x00", b"")
@ -727,11 +719,7 @@ class FreeTypeFont:
:param axes: A list of values for each axis. :param axes: A list of values for each axis.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.
""" """
try: self.font.setvaraxes(axes)
self.font.setvaraxes(axes)
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
class TransposedFont: class TransposedFont:

View File

@ -1259,8 +1259,6 @@ glyph_error:
return NULL; return NULL;
} }
#if FREETYPE_MAJOR > 2 || (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) || \
(FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)
static PyObject * static PyObject *
font_getvarnames(FontObject *self) { font_getvarnames(FontObject *self) {
int error; int error;
@ -1470,7 +1468,6 @@ font_setvaraxes(FontObject *self, PyObject *args) {
Py_RETURN_NONE; Py_RETURN_NONE;
} }
#endif
static void static void
font_dealloc(FontObject *self) { font_dealloc(FontObject *self) {
@ -1490,13 +1487,10 @@ static PyMethodDef font_methods[] = {
{"getsize", (PyCFunction)font_getsize, METH_VARARGS}, {"getsize", (PyCFunction)font_getsize, METH_VARARGS},
{"getlength", (PyCFunction)font_getlength, METH_VARARGS}, {"getlength", (PyCFunction)font_getlength, METH_VARARGS},
{"hascharacters", (PyCFunction)font_hascharacters, METH_VARARGS}, {"hascharacters", (PyCFunction)font_hascharacters, METH_VARARGS},
#if FREETYPE_MAJOR > 2 || (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) || \
(FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)
{"getvarnames", (PyCFunction)font_getvarnames, METH_NOARGS}, {"getvarnames", (PyCFunction)font_getvarnames, METH_NOARGS},
{"getvaraxes", (PyCFunction)font_getvaraxes, METH_NOARGS}, {"getvaraxes", (PyCFunction)font_getvaraxes, METH_NOARGS},
{"setvarname", (PyCFunction)font_setvarname, METH_VARARGS}, {"setvarname", (PyCFunction)font_setvarname, METH_VARARGS},
{"setvaraxes", (PyCFunction)font_setvaraxes, METH_VARARGS}, {"setvaraxes", (PyCFunction)font_setvaraxes, METH_VARARGS},
#endif
{NULL, NULL} {NULL, NULL}
}; };