diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 1459a87eb13..02d0b29d8c8 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -948,6 +948,7 @@ def test_getxmp(self) -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index c7c9f6fab0b..46714a0f00b 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -683,6 +683,7 @@ def test_getxmp(self) -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 06591a29a37..17dab25e91b 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -759,6 +759,7 @@ def test_getxmp(self) -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index 8759412408c..c3df4ad7bcb 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -129,6 +129,7 @@ def test_getxmp() -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info assert ( im.getxmp()["xmpmeta"]["xmptk"] == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 " diff --git a/Tests/test_image.py b/Tests/test_image.py index 0d7d034806c..35a7a49a876 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -909,6 +909,10 @@ def test_exif_hide_offsets(self) -> None: assert tag not in exif.get_ifd(0x8769) assert exif.get_ifd(0xA005) + def test_empty_xmp(self) -> None: + with Image.open("Tests/images/hopper.gif") as im: + assert im.getxmp() == {} + @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 1c095a11453..e83c8787f0a 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -195,6 +195,7 @@ This helps to get the bounding box coordinates of the input image:: .. automethod:: PIL.Image.Image.getpalette .. automethod:: PIL.Image.Image.getpixel .. automethod:: PIL.Image.Image.getprojection +.. automethod:: PIL.Image.Image.getxmp .. automethod:: PIL.Image.Image.histogram .. automethod:: PIL.Image.Image.paste .. automethod:: PIL.Image.Image.point diff --git a/docs/releasenotes/8.3.0.rst b/docs/releasenotes/8.3.0.rst index 9f46cc1e9e9..4ef914f6471 100644 --- a/docs/releasenotes/8.3.0.rst +++ b/docs/releasenotes/8.3.0.rst @@ -18,9 +18,9 @@ is not secure. - :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead. -- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It - will now use ``defusedxml`` instead. If the dependency is not present, an empty - dictionary will be returned and a warning raised. +- ``getxmp()`` was added to :py:class:`~PIL.JpegImagePlugin.JpegImageFile` in Pillow + 8.2.0. It will now use ``defusedxml`` instead. If the dependency is not present, an + empty dictionary will be returned and a warning raised. Deprecations ============ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 4725811274e..57da58522e7 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1459,7 +1459,14 @@ def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def _getxmp(self, xmp_tags): + def getxmp(self): + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ + def get_name(tag: str) -> str: return re.sub("^{[^}]+}", "", tag) @@ -1486,9 +1493,10 @@ def get_value(element): if ElementTree is None: warnings.warn("XMP data cannot be read without defusedxml dependency") return {} - else: - root = ElementTree.fromstring(xmp_tags) - return {get_name(root.tag): get_value(root)} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"]) + return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: """ diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index cbe189cc926..6d2814345a3 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -717,6 +717,9 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image exif_image.info["XML:com.adobe.xmp"] = re.sub( pattern, "", exif_image.info["XML:com.adobe.xmp"] ) + exif_image.info["xmp"] = re.sub( + pattern.encode(), b"", exif_image.info["xmp"] + ) if not in_place: return transposed_image elif not in_place: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 0c8a678887e..7a32f1cdec0 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -95,6 +95,8 @@ def APP(self, marker): else: self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + self.info["xmp"] = s.split(b"\x00")[1] elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change @@ -500,21 +502,6 @@ def _getexif(self) -> dict[str, Any] | None: def _getmp(self): return _getmp(self) - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - - for segment, content in self.applist: - if segment == "APP1": - marker, xmp_tags = content.split(b"\x00")[:2] - if marker == b"http://ns.adobe.com/xap/1.0/": - return self._getxmp(xmp_tags) - return {} - def _getexif(self) -> dict[str, Any] | None: if "exif" not in self.info: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 927d6c0cfbd..a2ca9399a25 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -624,6 +624,8 @@ def chunk_iTXt(self, pos: int, length: int) -> bytes: return s else: return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v try: k = k.decode("latin-1", "strict") lang = lang.decode("utf-8", "strict") @@ -1053,19 +1055,6 @@ def getexif(self) -> Image.Exif: return super().getexif() - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return ( - self._getxmp(self.info["XML:com.adobe.xmp"]) - if "XML:com.adobe.xmp" in self.info - else {} - ) - # -------------------------------------------------------------------- # PNG writer diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 833e12d2b37..b8e48692904 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1197,6 +1197,10 @@ def _seek(self, frame: int) -> None: self.__frame += 1 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] + elif "xmp" in self.info: + del self.info["xmp"] self._reload_exif() # fill the legacy tag/ifd entries self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) @@ -1207,15 +1211,6 @@ def tell(self) -> int: """Return the current frame number""" return self.__frame - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.tag_v2[XMP]) if XMP in self.tag_v2 else {} - def get_photoshop_blocks(self): """ Returns a dictionary of Photoshop "Image Resource Blocks". diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 97debc2edc9..59be5bf9d31 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -101,15 +101,6 @@ def _getexif(self) -> dict[str, Any] | None: return None return self.getexif()._get_merged_dict() - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.info["xmp"]) if "xmp" in self.info else {} - def seek(self, frame: int) -> None: if not self._seek_check(frame): return