diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 5d2157651df..c895c3a8d06 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -943,6 +943,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 19462dcb5a4..eb2c8a5f145 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 8821fb46a84..56b9148d167 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 742d0dfe406..042c0dccbb4 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -897,6 +897,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 0d9b4d93d77..767c833b39c 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -197,6 +197,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 958b95e3b0f..7d54b124bc0 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1439,7 +1439,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): return re.sub("^{[^}]+}", "", tag) @@ -1466,9 +1473,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 33db8fa50c7..48bde39fe26 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 909911dfe37..be6b6959a09 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -94,6 +94,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 @@ -499,21 +501,6 @@ def _getexif(self): def _getmp(self): return _getmp(self) - def getxmp(self): - """ - 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): if "exif" not in self.info: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index c74cbccf1bf..14622349b81 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -606,6 +606,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") @@ -1032,19 +1034,6 @@ def getexif(self): return super().getexif() - def getxmp(self): - """ - 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 54faa59c55f..7ba71b58edd 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1192,6 +1192,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) @@ -1202,15 +1206,6 @@ def tell(self) -> int: """Return the current frame number""" return self.__frame - def getxmp(self): - """ - 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 cae124e9f4c..43e1410c7a4 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -100,15 +100,6 @@ def _getexif(self): return None return self.getexif()._get_merged_dict() - def getxmp(self): - """ - 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