diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 1583435c1..b46811f5a 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -40,7 +40,7 @@ ARCHIVE_SDIR=pillow-depends-main FREETYPE_VERSION=2.13.3 HARFBUZZ_VERSION=11.2.1 LIBPNG_VERSION=1.6.48 -JPEGTURBO_VERSION=3.1.0 +JPEGTURBO_VERSION=3.1.1 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.8.1 TIFF_VERSION=4.7.0 diff --git a/Tests/images/imagedraw_rectangle_I.tiff b/Tests/images/imagedraw_rectangle_I.tiff index 9b9eda883..f0cb534b6 100644 Binary files a/Tests/images/imagedraw_rectangle_I.tiff and b/Tests/images/imagedraw_rectangle_I.tiff differ diff --git a/Tests/images/op_index.qoi b/Tests/images/op_index.qoi new file mode 100644 index 000000000..e626aafe6 Binary files /dev/null and b/Tests/images/op_index.qoi differ diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 41e2b5416..c7d1f4df4 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -288,12 +288,13 @@ def test_non_integer_token(tmp_path: Path) -> None: 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" 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): pass diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index 25cd20748..4222d2b94 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -32,6 +32,12 @@ def test_invalid_file() -> None: 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: f = tmp_path / "temp.qoi" diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index d0d394aa9..73046eb5f 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -14,6 +14,7 @@ from PIL import ( ImageFile, JpegImagePlugin, TiffImagePlugin, + TiffTags, UnidentifiedImageError, ) from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION @@ -900,6 +901,29 @@ class TestFileTiff: assert description[0]["format"] == "image/tiff" 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: with Image.open("Tests/images/lab.tif") as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) diff --git a/Tests/test_image.py b/Tests/test_image.py index 4cc841603..b018b4309 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -974,6 +974,11 @@ class TestImage: assert tag not in exif.get_ifd(0x8769) 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: with Image.open("Tests/images/hopper.gif") as im: if ElementTree is None: @@ -990,7 +995,7 @@ class TestImage: im = Image.new("RGB", (1, 1)) im.info["xmp"] = ( b'\n' - b'\n\x00\x00' + b'\n\x00\x00 ' ) if ElementTree is None: with pytest.warns( diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index ffe9c0979..37669a2e5 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -783,9 +783,10 @@ def test_rectangle_I16(bbox: Coords) -> None: draw = ImageDraw.Draw(im) # Act - draw.rectangle(bbox, outline=0xFFFF) + draw.rectangle(bbox, outline=0xCDEF) # Assert + assert im.getpixel((X0, Y0)) == 0xCDEF assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_I.tiff") diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 073e5415c..976f62384 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -52,3 +52,17 @@ def test_tiff_crashes(test_file: str) -> None: pytest.skip("test image not found") except OSError: 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") diff --git a/docs/reference/ImageFont.rst b/docs/reference/ImageFont.rst index 8b2f92323..aac55fe6b 100644 --- a/docs/reference/ImageFont.rst +++ b/docs/reference/ImageFont.rst @@ -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 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:: 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 diff --git a/src/PIL/Image.py b/src/PIL/Image.py index ed2f728aa..7e9540e48 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1511,7 +1511,7 @@ class Image: return {} if "xmp" not in self.info: return {} - root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00")) + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00 ")) return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: @@ -1542,10 +1542,11 @@ class Image: # XMP tags if ExifTags.Base.Orientation not in self._exif: 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")): - xmp_tags = xmp_tags.decode("utf-8") + pattern = rb'tiff:Orientation(="|>)([0-9])' if xmp_tags: - match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) + match = re.search(pattern, xmp_tags) if match: self._exif[ExifTags.Base.Orientation] = int(match[2]) diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 299405ae0..458d586c4 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -54,7 +54,7 @@ class PcxImageFile(ImageFile.ImageFile): # header assert self.fp is not None - s = self.fp.read(128) + s = self.fp.read(68) if not _accept(s): msg = "not a PCX file" raise SyntaxError(msg) @@ -66,6 +66,8 @@ class PcxImageFile(ImageFile.ImageFile): raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) + offset = self.fp.tell() + 60 + # format version = s[1] bits = s[3] @@ -102,7 +104,6 @@ class PcxImageFile(ImageFile.ImageFile): break if mode == "P": self.palette = ImagePalette.raw("RGB", s[1:]) - self.fp.seek(128) elif version == 5 and bits == 8 and planes == 3: mode = "RGB" @@ -128,9 +129,7 @@ class PcxImageFile(ImageFile.ImageFile): bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [ - ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) - ] + self.tile = [ImageFile._Tile("pcx", bbox, offset, (rawmode, planes * stride))] # -------------------------------------------------------------------- diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 03afa2d2e..db34d107a 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -94,8 +94,8 @@ class PpmImageFile(ImageFile.ImageFile): msg = "Reached EOF while reading header" raise ValueError(msg) elif len(token) > 10: - msg = f"Token too long in file header: {token.decode()}" - raise ValueError(msg) + msg_too_long = b"Token too long in file header: %s" % token + raise ValueError(msg_too_long) return token def _open(self) -> None: diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index c123249ea..aab0533e3 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -54,7 +54,7 @@ class QoiDecoder(ImageFile.PyDecoder): assert self.fd is not None self._previously_seen_pixels = {} - self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) + self._previous_pixel = bytearray((0, 0, 0, 255)) data = bytearray() bands = Image.getmodebands(self.mode) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 88af9162e..946fbd531 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1217,9 +1217,10 @@ class TiffImageFile(ImageFile.ImageFile): return self._seek(frame) 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 def _seek(self, frame: int) -> None: @@ -1259,7 +1260,10 @@ class TiffImageFile(ImageFile.ImageFile): self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) 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: del self.info["xmp"] self._reload_exif() diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 70f267ae4..27cac687e 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -104,8 +104,6 @@ point32rgba(Imaging im, int x, int y, int ink) { static inline void hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { - int pixelwidth; - if (y0 >= 0 && y0 < im->ysize) { if (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; } if (x0 <= x1) { - pixelwidth = strncmp(im->mode, "I;16", 4) == 0 ? 2 : 1; - if (mask == NULL) { - memset( - im->image8[y0] + x0 * pixelwidth, - (UINT8)ink, - (x1 - x0 + 1) * pixelwidth - ); + int bigendian = -1; + if (strncmp(im->mode, "I;16", 4) == 0) { + bigendian = + ( +#ifdef WORDS_BIGENDIAN + 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 { UINT8 *p = im->image8[y0]; while (x0 <= x1) { - if (mask->image8[y0][x0]) { - p[x0 * pixelwidth] = ink; - if (pixelwidth == 2) { - p[x0 * pixelwidth + 1] = ink; + if (mask == NULL || mask->image8[y0][x0]) { + if (bigendian == -1) { + p[x0] = ink; + } else { + p[x0 * 2 + (bigendian ? 1 : 0)] = ink; + p[x0 * 2 + (bigendian ? 0 : 1)] = ink >> 8; } } x0++; diff --git a/src/map.c b/src/map.c index c66702981..9a3144ab9 100644 --- a/src/map.c +++ b/src/map.c @@ -137,6 +137,7 @@ PyImaging_MapBuffer(PyObject *self, PyObject *args) { } } + im->read_only = view.readonly; im->destroy = mapping_destroy_buffer; Py_INCREF(target); diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 6e176e29c..0cc383733 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -114,7 +114,7 @@ V = { "FREETYPE": "2.13.3", "FRIBIDI": "1.0.16", "HARFBUZZ": "11.2.1", - "JPEGTURBO": "3.1.0", + "JPEGTURBO": "3.1.1", "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.3.4",