Merge branch 'main' into qoi_write

This commit is contained in:
Andrew Murray 2025-06-11 22:58:13 +10:00 committed by GitHub
commit 2f5137fdce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 101 additions and 34 deletions

View File

@ -40,7 +40,7 @@ ARCHIVE_SDIR=pillow-depends-main
FREETYPE_VERSION=2.13.3 FREETYPE_VERSION=2.13.3
HARFBUZZ_VERSION=11.2.1 HARFBUZZ_VERSION=11.2.1
LIBPNG_VERSION=1.6.48 LIBPNG_VERSION=1.6.48
JPEGTURBO_VERSION=3.1.0 JPEGTURBO_VERSION=3.1.1
OPENJPEG_VERSION=2.5.3 OPENJPEG_VERSION=2.5.3
XZ_VERSION=5.8.1 XZ_VERSION=5.8.1
TIFF_VERSION=4.7.0 TIFF_VERSION=4.7.0

BIN
Tests/images/op_index.qoi Normal file

Binary file not shown.

View File

@ -288,12 +288,13 @@ def test_non_integer_token(tmp_path: Path) -> None:
pass pass
def test_header_token_too_long(tmp_path: Path) -> None: @pytest.mark.parametrize("data", (b"P3\x0cAAAAAAAAAA\xee", b"P6\n 01234567890"))
def test_header_token_too_long(tmp_path: Path, data: bytes) -> None:
path = tmp_path / "temp.ppm" path = tmp_path / "temp.ppm"
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(b"P6\n 01234567890") f.write(data)
with pytest.raises(ValueError, match="Token too long in file header: 01234567890"): with pytest.raises(ValueError, match="Token too long in file header: "):
with Image.open(path): with Image.open(path):
pass pass

View File

@ -32,6 +32,12 @@ def test_invalid_file() -> None:
QoiImagePlugin.QoiImageFile(invalid_file) QoiImagePlugin.QoiImageFile(invalid_file)
def test_op_index() -> None:
# QOI_OP_INDEX as the first chunk
with Image.open("Tests/images/op_index.qoi") as im:
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
def test_save(tmp_path: Path) -> None: def test_save(tmp_path: Path) -> None:
f = tmp_path / "temp.qoi" f = tmp_path / "temp.qoi"

View File

@ -14,6 +14,7 @@ from PIL import (
ImageFile, ImageFile,
JpegImagePlugin, JpegImagePlugin,
TiffImagePlugin, TiffImagePlugin,
TiffTags,
UnidentifiedImageError, UnidentifiedImageError,
) )
from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION
@ -900,6 +901,29 @@ class TestFileTiff:
assert description[0]["format"] == "image/tiff" assert description[0]["format"] == "image/tiff"
assert description[3]["BitsPerSample"]["Seq"]["li"] == ["8", "8", "8"] assert description[3]["BitsPerSample"]["Seq"]["li"] == ["8", "8", "8"]
def test_getxmp_undefined(self, tmp_path: Path) -> None:
tmpfile = tmp_path / "temp.tif"
im = Image.new("L", (1, 1))
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd.tagtype[700] = TiffTags.UNDEFINED
with Image.open("Tests/images/lab.tif") as im_xmp:
ifd[700] = im_xmp.info["xmp"]
im.save(tmpfile, tiffinfo=ifd)
with Image.open(tmpfile) as im_reloaded:
if ElementTree is None:
with pytest.warns(
UserWarning,
match="XMP data cannot be read without defusedxml dependency",
):
assert im_reloaded.getxmp() == {}
else:
assert "xmp" in im_reloaded.info
xmp = im_reloaded.getxmp()
description = xmp["xmpmeta"]["RDF"]["Description"]
assert description[0]["format"] == "image/tiff"
def test_get_photoshop_blocks(self) -> None: def test_get_photoshop_blocks(self) -> None:
with Image.open("Tests/images/lab.tif") as im: with Image.open("Tests/images/lab.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile) assert isinstance(im, TiffImagePlugin.TiffImageFile)

View File

@ -974,6 +974,11 @@ class TestImage:
assert tag not in exif.get_ifd(0x8769) assert tag not in exif.get_ifd(0x8769)
assert exif.get_ifd(0xA005) assert exif.get_ifd(0xA005)
def test_exif_from_xmp_bytes(self) -> None:
im = Image.new("RGB", (1, 1))
im.info["xmp"] = b'\xff tiff:Orientation="2"'
assert im.getexif()[274] == 2
def test_empty_xmp(self) -> None: def test_empty_xmp(self) -> None:
with Image.open("Tests/images/hopper.gif") as im: with Image.open("Tests/images/hopper.gif") as im:
if ElementTree is None: if ElementTree is None:

View File

@ -783,9 +783,10 @@ def test_rectangle_I16(bbox: Coords) -> None:
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
# Act # Act
draw.rectangle(bbox, outline=0xFFFF) draw.rectangle(bbox, outline=0xCDEF)
# Assert # Assert
assert im.getpixel((X0, Y0)) == 0xCDEF
assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_I.tiff") assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_I.tiff")

View File

@ -52,3 +52,17 @@ def test_tiff_crashes(test_file: str) -> None:
pytest.skip("test image not found") pytest.skip("test image not found")
except OSError: except OSError:
pass pass
def test_tiff_mmap() -> None:
try:
with Image.open("Tests/images/crash_mmap.tif") as im:
im.seek(1)
im.load()
im.seek(0)
im.load()
except FileNotFoundError:
if on_ci():
raise
pytest.skip("test image not found")

View File

@ -18,6 +18,9 @@ OpenType fonts (as well as other font formats supported by the FreeType
library). For earlier versions, TrueType support is only available as part of library). For earlier versions, TrueType support is only available as part of
the imToolkit package. the imToolkit package.
When measuring text sizes, this module will not break at newline characters. For
multiline text, see the :py:mod:`~PIL.ImageDraw` module.
.. warning:: .. warning::
To protect against potential DOS attacks when using arbitrary strings as To protect against potential DOS attacks when using arbitrary strings as
text input, Pillow will raise a :py:exc:`ValueError` if the number of characters text input, Pillow will raise a :py:exc:`ValueError` if the number of characters

View File

@ -1542,10 +1542,11 @@ class Image:
# XMP tags # XMP tags
if ExifTags.Base.Orientation not in self._exif: if ExifTags.Base.Orientation not in self._exif:
xmp_tags = self.info.get("XML:com.adobe.xmp") xmp_tags = self.info.get("XML:com.adobe.xmp")
pattern: str | bytes = r'tiff:Orientation(="|>)([0-9])'
if not xmp_tags and (xmp_tags := self.info.get("xmp")): if not xmp_tags and (xmp_tags := self.info.get("xmp")):
xmp_tags = xmp_tags.decode("utf-8") pattern = rb'tiff:Orientation(="|>)([0-9])'
if xmp_tags: if xmp_tags:
match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) match = re.search(pattern, xmp_tags)
if match: if match:
self._exif[ExifTags.Base.Orientation] = int(match[2]) self._exif[ExifTags.Base.Orientation] = int(match[2])

View File

@ -54,7 +54,7 @@ class PcxImageFile(ImageFile.ImageFile):
# header # header
assert self.fp is not None assert self.fp is not None
s = self.fp.read(128) s = self.fp.read(68)
if not _accept(s): if not _accept(s):
msg = "not a PCX file" msg = "not a PCX file"
raise SyntaxError(msg) raise SyntaxError(msg)
@ -66,6 +66,8 @@ class PcxImageFile(ImageFile.ImageFile):
raise SyntaxError(msg) raise SyntaxError(msg)
logger.debug("BBox: %s %s %s %s", *bbox) logger.debug("BBox: %s %s %s %s", *bbox)
offset = self.fp.tell() + 60
# format # format
version = s[1] version = s[1]
bits = s[3] bits = s[3]
@ -102,7 +104,6 @@ class PcxImageFile(ImageFile.ImageFile):
break break
if mode == "P": if mode == "P":
self.palette = ImagePalette.raw("RGB", s[1:]) self.palette = ImagePalette.raw("RGB", s[1:])
self.fp.seek(128)
elif version == 5 and bits == 8 and planes == 3: elif version == 5 and bits == 8 and planes == 3:
mode = "RGB" mode = "RGB"
@ -128,9 +129,7 @@ class PcxImageFile(ImageFile.ImageFile):
bbox = (0, 0) + self.size bbox = (0, 0) + self.size
logger.debug("size: %sx%s", *self.size) logger.debug("size: %sx%s", *self.size)
self.tile = [ self.tile = [ImageFile._Tile("pcx", bbox, offset, (rawmode, planes * stride))]
ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride))
]
# -------------------------------------------------------------------- # --------------------------------------------------------------------

View File

@ -94,8 +94,8 @@ class PpmImageFile(ImageFile.ImageFile):
msg = "Reached EOF while reading header" msg = "Reached EOF while reading header"
raise ValueError(msg) raise ValueError(msg)
elif len(token) > 10: elif len(token) > 10:
msg = f"Token too long in file header: {token.decode()}" msg_too_long = b"Token too long in file header: %s" % token
raise ValueError(msg) raise ValueError(msg_too_long)
return token return token
def _open(self) -> None: def _open(self) -> None:

View File

@ -54,7 +54,7 @@ class QoiDecoder(ImageFile.PyDecoder):
assert self.fd is not None assert self.fd is not None
self._previously_seen_pixels = {} self._previously_seen_pixels = {}
self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) self._previous_pixel = bytearray((0, 0, 0, 255))
data = bytearray() data = bytearray()
bands = Image.getmodebands(self.mode) bands = Image.getmodebands(self.mode)

View File

@ -1217,9 +1217,10 @@ class TiffImageFile(ImageFile.ImageFile):
return return
self._seek(frame) self._seek(frame)
if self._im is not None and ( if self._im is not None and (
self.im.size != self._tile_size or self.im.mode != self.mode self.im.size != self._tile_size
or self.im.mode != self.mode
or self.readonly
): ):
# The core image will no longer be used
self._im = None self._im = None
def _seek(self, frame: int) -> None: def _seek(self, frame: int) -> None:
@ -1259,7 +1260,10 @@ class TiffImageFile(ImageFile.ImageFile):
self.fp.seek(self._frame_pos[frame]) self.fp.seek(self._frame_pos[frame])
self.tag_v2.load(self.fp) self.tag_v2.load(self.fp)
if XMP in self.tag_v2: if XMP in self.tag_v2:
self.info["xmp"] = self.tag_v2[XMP] xmp = self.tag_v2[XMP]
if isinstance(xmp, tuple) and len(xmp) == 1:
xmp = xmp[0]
self.info["xmp"] = xmp
elif "xmp" in self.info: elif "xmp" in self.info:
del self.info["xmp"] del self.info["xmp"]
self._reload_exif() self._reload_exif()

View File

@ -104,8 +104,6 @@ point32rgba(Imaging im, int x, int y, int ink) {
static inline void static inline void
hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) {
int pixelwidth;
if (y0 >= 0 && y0 < im->ysize) { if (y0 >= 0 && y0 < im->ysize) {
if (x0 < 0) { if (x0 < 0) {
x0 = 0; x0 = 0;
@ -118,20 +116,30 @@ hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) {
x1 = im->xsize - 1; x1 = im->xsize - 1;
} }
if (x0 <= x1) { if (x0 <= x1) {
pixelwidth = strncmp(im->mode, "I;16", 4) == 0 ? 2 : 1; int bigendian = -1;
if (mask == NULL) { if (strncmp(im->mode, "I;16", 4) == 0) {
memset( bigendian =
im->image8[y0] + x0 * pixelwidth, (
(UINT8)ink, #ifdef WORDS_BIGENDIAN
(x1 - x0 + 1) * pixelwidth strcmp(im->mode, "I;16") == 0 || strcmp(im->mode, "I;16L") == 0
); #else
strcmp(im->mode, "I;16B") == 0
#endif
)
? 1
: 0;
}
if (mask == NULL && bigendian == -1) {
memset(im->image8[y0] + x0, (UINT8)ink, (x1 - x0 + 1));
} else { } else {
UINT8 *p = im->image8[y0]; UINT8 *p = im->image8[y0];
while (x0 <= x1) { while (x0 <= x1) {
if (mask->image8[y0][x0]) { if (mask == NULL || mask->image8[y0][x0]) {
p[x0 * pixelwidth] = ink; if (bigendian == -1) {
if (pixelwidth == 2) { p[x0] = ink;
p[x0 * pixelwidth + 1] = ink; } else {
p[x0 * 2 + (bigendian ? 1 : 0)] = ink;
p[x0 * 2 + (bigendian ? 0 : 1)] = ink >> 8;
} }
} }
x0++; x0++;

View File

@ -137,6 +137,7 @@ PyImaging_MapBuffer(PyObject *self, PyObject *args) {
} }
} }
im->read_only = view.readonly;
im->destroy = mapping_destroy_buffer; im->destroy = mapping_destroy_buffer;
Py_INCREF(target); Py_INCREF(target);

View File

@ -114,7 +114,7 @@ V = {
"FREETYPE": "2.13.3", "FREETYPE": "2.13.3",
"FRIBIDI": "1.0.16", "FRIBIDI": "1.0.16",
"HARFBUZZ": "11.2.1", "HARFBUZZ": "11.2.1",
"JPEGTURBO": "3.1.0", "JPEGTURBO": "3.1.1",
"LCMS2": "2.17", "LCMS2": "2.17",
"LIBAVIF": "1.3.0", "LIBAVIF": "1.3.0",
"LIBIMAGEQUANT": "4.3.4", "LIBIMAGEQUANT": "4.3.4",