Merge branch 'main' into update-release-notes

This commit is contained in:
Andrew Murray 2024-01-01 19:03:59 +11:00 committed by GitHub
commit ca94dae26b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 222 additions and 78 deletions

View File

@ -2,15 +2,14 @@
[report]
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma:
pragma: no cover
# Don't complain if non-runnable code isn't run:
exclude_also =
# Don't complain if non-runnable code isn't run
if 0:
if __name__ == .__main__.:
# Don't complain about debug code
if DEBUG:
# Don't complain about compatibility code for missing optional dependencies
except ImportError
[run]
omit =

18
.github/problem-matchers/gcc.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"__comment": "Based on vscode-cpptools' Extension/package.json gcc rule",
"problemMatcher": [
{
"owner": "gcc-problem-matcher",
"pattern": [
{
"regexp": "^\\s*(.*):(\\d+):(\\d+):\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
]
}
]
}

View File

@ -86,6 +86,10 @@ jobs:
env:
GHA_PYTHON_VERSION: ${{ matrix.python-version }}
- name: Register gcc problem matcher
if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'"
run: echo "::add-matcher::.github/problem-matchers/gcc.json"
- name: Build
run: |
.ci/build.sh

View File

@ -5,6 +5,30 @@ Changelog (Pillow)
10.2.0 (unreleased)
-------------------
- Trim glyph size in ImageFont.getmask() #7669
[radarhere]
- Deprecate IptcImagePlugin helpers #7664
[nulano, hugovk, radarhere]
- Allow uncompressed TIFF images to be saved in chunks #7650
[radarhere]
- Concatenate multiple JPEG EXIF markers #7496
[radarhere]
- Changed IPTC tile tuple to match other plugins #7661
[radarhere]
- Do not assign new fp attribute when exiting context manager #7566
[radarhere]
- Support arbitrary masks for uncompressed RGB DDS images #7589
[radarhere, akx]
- Support setting ROWSPERSTRIP tag #7654
[radarhere]
- Apply ImageFont.MAX_STRING_LENGTH to ImageFont.getmask() #7662
[radarhere]

View File

@ -6,18 +6,21 @@ import pytest
from PIL import Image, IptcImagePlugin
from .helper import hopper
from .helper import assert_image_equal, hopper
TEST_FILE = "Tests/images/iptc.jpg"
def test_open():
expected = Image.new("L", (1, 1))
f = BytesIO(
b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c"
b"\x03\x14\x00\x01\x01\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x00"
b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c\x03\x14\x00\x01\x01"
b"\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x01\x00"
)
with Image.open(f) as im:
assert im.tile == [("iptc", (0, 0, 1, 1), 25, "raw")]
assert_image_equal(im, expected)
def test_getiptcinfo_jpg_none():
@ -87,24 +90,28 @@ def test_i():
c = b"a"
# Act
ret = IptcImagePlugin.i(c)
with pytest.warns(DeprecationWarning):
ret = IptcImagePlugin.i(c)
# Assert
assert ret == 97
def test_dump():
def test_dump(monkeypatch):
# Arrange
c = b"abc"
# Temporarily redirect stdout
old_stdout = sys.stdout
sys.stdout = mystdout = StringIO()
mystdout = StringIO()
monkeypatch.setattr(sys, "stdout", mystdout)
# Act
IptcImagePlugin.dump(c)
# Reset stdout
sys.stdout = old_stdout
with pytest.warns(DeprecationWarning):
IptcImagePlugin.dump(c)
# Assert
assert mystdout.getvalue() == "61 62 63 \n"
def test_pad_deprecation():
with pytest.warns(DeprecationWarning):
assert IptcImagePlugin.PAD == b"\0\0\0\0"

View File

@ -484,13 +484,13 @@ class TestFileTiff:
outfile = str(tmp_path / "temp.tif")
with Image.open("Tests/images/ifd_tag_type.tiff") as im:
exif = im.getexif()
exif[256] = 100
exif[264] = 100
im.save(outfile, exif=exif)
with Image.open(outfile) as im:
exif = im.getexif()
assert exif[256] == 100
assert exif[264] == 100
def test_reload_exif_after_seek(self):
with Image.open("Tests/images/multipage.tiff") as im:
@ -781,6 +781,27 @@ class TestFileTiff:
4001,
]
def test_tiff_chunks(self, tmp_path):
tmpfile = str(tmp_path / "temp.tif")
im = hopper()
with open(tmpfile, "wb") as fp:
for y in range(0, 128, 32):
chunk = im.crop((0, y, 128, y + 32))
if y == 0:
chunk.save(
fp,
"TIFF",
tiffinfo={
TiffImagePlugin.IMAGEWIDTH: 128,
TiffImagePlugin.IMAGELENGTH: 128,
},
)
else:
fp.write(chunk.tobytes())
assert_image_equal_tofile(im, tmpfile)
def test_close_on_load_exclusive(self, tmp_path):
# similar to test_fd_leak, but runs on unixlike os
tmpfile = str(tmp_path / "temp.tif")

View File

@ -164,6 +164,7 @@ def test_change_stripbytecounts_tag_type(tmp_path):
# Resize the image so that STRIPBYTECOUNTS will be larger than a SHORT
im = im.resize((500, 500))
info[TiffImagePlugin.IMAGEWIDTH] = im.width
# STRIPBYTECOUNTS can be a SHORT or a LONG
info.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] = TiffTags.SHORT

View File

@ -1,14 +1,22 @@
from __future__ import annotations
import struct
import pytest
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont, features
from PIL import Image, ImageDraw, ImageFont, features, _util
from .helper import assert_image_equal_tofile
pytestmark = pytest.mark.skipif(
features.check_module("freetype2"),
reason="PILfont superseded if FreeType is supported",
)
original_core = ImageFont.core
def setup_module():
if features.check_module("freetype2"):
ImageFont.core = _util.DeferredError(ImportError)
def teardown_module():
ImageFont.core = original_core
def test_default_font():
@ -44,3 +52,23 @@ def test_textbbox():
default_font = ImageFont.load_default()
assert d.textlength("test", font=default_font) == 24
assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, 24, 11)
def test_decompression_bomb():
glyph = struct.pack(">hhhhhhhhhh", 1, 0, 0, 0, 256, 256, 0, 0, 256, 256)
fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256)
font = ImageFont.ImageFont()
font._load_pilfont_data(fp, Image.new("L", (256, 256)))
with pytest.raises(Image.DecompressionBombError):
font.getmask("A" * 1_000_000)
@pytest.mark.timeout(4)
def test_oom():
glyph = struct.pack(">hhhhhhhhhh", 1, 0, 0, 0, 32767, 32767, 0, 0, 32767, 32767)
fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256)
font = ImageFont.ImageFont()
font._load_pilfont_data(fp, Image.new("L", (1, 1)))
font.getmask("A" * 1_000_000)

View File

@ -57,15 +57,6 @@ def test_str_to_img():
assert_image_equal_tofile(A, "Tests/images/morph_a.png")
def create_lut():
for op in ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge"):
lb = ImageMorph.LutBuilder(op_name=op)
lut = lb.build_lut()
with open(f"Tests/images/{op}.lut", "wb") as f:
f.write(lut)
# create_lut()
@pytest.mark.parametrize(
"op", ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge")
)

View File

@ -44,6 +44,17 @@ ImageFile.raise_oserror
error codes returned by a codec's ``decode()`` method, which ImageFile already does
automatically.
IptcImageFile helper functions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. deprecated:: 10.2.0
The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant
``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow
12.0.0 (2025-10-15). These are undocumented helper functions intended
for internal use, so there is no replacement. They can each be replaced
by a single line of code using builtin functions in Python.
Removed features
----------------

View File

@ -12,6 +12,15 @@ ImageFile.raise_oserror
error codes returned by a codec's ``decode()`` method, which ImageFile already does
automatically.
IptcImageFile helper functions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant
``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow
12.0.0 (2025-10-15). These are undocumented helper functions intended
for internal use, so there is no replacement. They can each be replaced
by a single line of code using builtin functions in Python.
API Changes
===========
@ -60,6 +69,16 @@ Pillow will now raise a :py:exc:`ValueError` if the number of characters passed
This threshold can be changed by setting :py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. It
can be disabled by setting ``ImageFont.MAX_STRING_LENGTH = None``.
A decompression bomb check has also been added to
:py:meth:`PIL.ImageFont.ImageFont.getmask`.
ImageFont.getmask: Trim glyph size
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To protect against potential DOS attacks when using PIL fonts,
:py:class:`PIL.ImageFont.ImageFont` now trims the size of individual glyphs so that
they do not extend beyond the bitmap image.
ImageMath.eval: Restricted environment keys
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -3190,7 +3190,7 @@ def _decompression_bomb_check(size):
)
def open(fp, mode="r", formats=None):
def open(fp, mode="r", formats=None) -> Image:
"""
Opens and identifies the given image file.
@ -3415,7 +3415,7 @@ def merge(mode, bands):
# Plugin registry
def register_open(id, factory, accept=None):
def register_open(id, factory, accept=None) -> None:
"""
Register an image file plugin. This function should not be used
in application code.
@ -3469,7 +3469,7 @@ def register_save_all(id, driver):
SAVE_ALL[id.upper()] = driver
def register_extension(id, extension):
def register_extension(id, extension) -> None:
"""
Registers an image extension. This function should not be
used in application code.

View File

@ -396,7 +396,7 @@ class Color3DLUT(MultibandFilter):
if hasattr(table, "shape"):
try:
import numpy
except ImportError: # pragma: no cover
except ImportError:
pass
if numpy and isinstance(table, numpy.ndarray):

View File

@ -150,6 +150,7 @@ class ImageFont:
:py:mod:`PIL.Image.core` interface module.
"""
_string_length_check(text)
Image._decompression_bomb_check(self.font.getsize(text))
return self.font.getmask(text, mode)
def getbbox(self, text, *args, **kwargs):

View File

@ -16,31 +16,46 @@
#
from __future__ import annotations
import os
import tempfile
from io import BytesIO
from typing import Sequence
from . import Image, ImageFile
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._deprecate import deprecate
COMPRESSION = {1: "raw", 5: "jpeg"}
PAD = b"\0\0\0\0"
def __getattr__(name: str) -> bytes:
if name == "PAD":
deprecate("IptcImagePlugin.PAD", 12)
return b"\0\0\0\0"
msg = f"module '{__name__}' has no attribute '{name}'"
raise AttributeError(msg)
#
# Helpers
def _i(c: bytes) -> int:
return i32((b"\0\0\0\0" + c)[-4:])
def _i8(c: int | bytes) -> int:
return c if isinstance(c, int) else c[0]
def i(c):
return i32((PAD + c)[-4:])
def i(c: bytes) -> int:
""".. deprecated:: 10.2.0"""
deprecate("IptcImagePlugin.i", 12)
return _i(c)
def dump(c):
def dump(c: Sequence[int | bytes]) -> None:
""".. deprecated:: 10.2.0"""
deprecate("IptcImagePlugin.dump", 12)
for i in c:
print("%02x" % _i8(i), end=" ")
print()
@ -55,10 +70,10 @@ class IptcImageFile(ImageFile.ImageFile):
format = "IPTC"
format_description = "IPTC/NAA"
def getint(self, key):
return i(self.info[key])
def getint(self, key: tuple[int, int]) -> int:
return _i(self.info[key])
def field(self):
def field(self) -> tuple[tuple[int, int] | None, int]:
#
# get a IPTC field header
s = self.fp.read(5)
@ -80,13 +95,13 @@ class IptcImageFile(ImageFile.ImageFile):
elif size == 128:
size = 0
elif size > 128:
size = i(self.fp.read(size - 128))
size = _i(self.fp.read(size - 128))
else:
size = i16(s, 3)
return tag, size
def _open(self):
def _open(self) -> None:
# load descriptive fields
while True:
offset = self.fp.tell()
@ -142,12 +157,11 @@ class IptcImageFile(ImageFile.ImageFile):
self.fp.seek(offset)
# Copy image data to temporary file
o_fd, outfile = tempfile.mkstemp(text=False)
o = os.fdopen(o_fd)
o = BytesIO()
if compression == "raw":
# To simplify access to the extracted file,
# prepend a PPM header
o.write("P5\n%d %d\n255\n" % self.size)
o.write(b"P5\n%d %d\n255\n" % self.size)
while True:
type, size = self.field()
if type != (8, 10):
@ -158,17 +172,10 @@ class IptcImageFile(ImageFile.ImageFile):
break
o.write(s)
size -= len(s)
o.close()
try:
with Image.open(outfile) as _im:
_im.load()
self.im = _im.im
finally:
try:
os.unlink(outfile)
except OSError:
pass
with Image.open(o) as _im:
_im.load()
self.im = _im.im
Image.register_open(IptcImageFile.format, IptcImageFile)
@ -184,8 +191,6 @@ def getiptcinfo(im):
:returns: A dictionary containing IPTC information, or None if
no IPTC information block was found.
"""
import io
from . import JpegImagePlugin, TiffImagePlugin
data = None
@ -220,7 +225,7 @@ def getiptcinfo(im):
# parse the IPTC information chunk
im.info = {}
im.fp = io.BytesIO(data)
im.fp = BytesIO(data)
try:
im._open()

View File

@ -1704,28 +1704,27 @@ def _save(im, fp, filename):
colormap += [0] * (256 - colors)
ifd[COLORMAP] = colormap
# data orientation
stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8)
w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH]
stride = len(bits) * ((w * bits[0] + 7) // 8)
if ROWSPERSTRIP not in ifd:
# aim for given strip size (64 KB by default) when using libtiff writer
if libtiff:
im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE)
rows_per_strip = (
1 if stride == 0 else min(im_strip_size // stride, im.size[1])
)
rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h)
# JPEG encoder expects multiple of 8 rows
if compression == "jpeg":
rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1])
rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h)
else:
rows_per_strip = im.size[1]
rows_per_strip = h
if rows_per_strip == 0:
rows_per_strip = 1
ifd[ROWSPERSTRIP] = rows_per_strip
strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP]
strips_per_image = (im.size[1] + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP]
strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP]
if strip_byte_counts >= 2**16:
ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG
ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + (
stride * im.size[1] - strip_byte_counts * (strips_per_image - 1),
stride * h - strip_byte_counts * (strips_per_image - 1),
)
ifd[STRIPOFFSETS] = tuple(
range(0, strip_byte_counts * strips_per_image, strip_byte_counts)

View File

@ -2649,6 +2649,18 @@ _font_new(PyObject *self_, PyObject *args) {
self->glyphs[i].sy0 = S16(B16(glyphdata, 14));
self->glyphs[i].sx1 = S16(B16(glyphdata, 16));
self->glyphs[i].sy1 = S16(B16(glyphdata, 18));
// Do not allow glyphs to extend beyond bitmap image
// Helps prevent DOS by stopping cropped images being larger than the original
if (self->glyphs[i].sx1 > self->bitmap->xsize) {
self->glyphs[i].dx1 -= self->glyphs[i].sx1 - self->bitmap->xsize;
self->glyphs[i].sx1 = self->bitmap->xsize;
}
if (self->glyphs[i].sy1 > self->bitmap->ysize) {
self->glyphs[i].dy1 -= self->glyphs[i].sy1 - self->bitmap->ysize;
self->glyphs[i].sy1 = self->bitmap->ysize;
}
if (self->glyphs[i].dy0 < y0) {
y0 = self->glyphs[i].dy0;
}
@ -2721,7 +2733,7 @@ _font_text_asBytes(PyObject *encoded_string, unsigned char **text) {
static PyObject *
_font_getmask(ImagingFontObject *self, PyObject *args) {
Imaging im;
Imaging bitmap;
Imaging bitmap = NULL;
int x, b;
int i = 0;
int status;
@ -2730,7 +2742,7 @@ _font_getmask(ImagingFontObject *self, PyObject *args) {
PyObject *encoded_string;
unsigned char *text;
char *mode = "";
char *mode;
if (!PyArg_ParseTuple(args, "O|s:getmask", &encoded_string, &mode)) {
return NULL;
@ -2753,10 +2765,13 @@ _font_getmask(ImagingFontObject *self, PyObject *args) {
b = self->baseline;
for (x = 0; text[i]; i++) {
glyph = &self->glyphs[text[i]];
bitmap =
ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1);
if (!bitmap) {
goto failed;
if (i == 0 || text[i] != text[i - 1]) {
ImagingDelete(bitmap);
bitmap =
ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1);
if (!bitmap) {
goto failed;
}
}
status = ImagingPaste(
im,
@ -2766,17 +2781,18 @@ _font_getmask(ImagingFontObject *self, PyObject *args) {
glyph->dy0 + b,
glyph->dx1 + x,
glyph->dy1 + b);
ImagingDelete(bitmap);
if (status < 0) {
goto failed;
}
x = x + glyph->dx;
b = b + glyph->dy;
}
ImagingDelete(bitmap);
free(text);
return PyImagingNew(im);
failed:
ImagingDelete(bitmap);
free(text);
ImagingDelete(im);
Py_RETURN_NONE;