From 735de39e966185aaa2042c0c8a825a6ac4587439 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 20 May 2024 22:45:04 +1000 Subject: [PATCH] Improved consistency of XMP handling --- Tests/test_file_jpeg.py | 1 + Tests/test_file_png.py | 1 + Tests/test_file_tiff.py | 1 + Tests/test_file_webp_metadata.py | 1 + Tests/test_image.py | 4 ++++ docs/reference/Image.rst | 1 + src/PIL/Image.py | 16 ++++++++++++---- src/PIL/ImageOps.py | 5 ++--- src/PIL/JpegImagePlugin.py | 17 ++--------------- src/PIL/PngImagePlugin.py | 15 ++------------- src/PIL/TiffImagePlugin.py | 13 ++++--------- src/PIL/WebPImagePlugin.py | 9 --------- 12 files changed, 31 insertions(+), 53 deletions(-) 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/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..b77de0f255b 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -714,9 +714,8 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image r'tiff:Orientation="([0-9])"', r"([0-9])", ): - exif_image.info["XML:com.adobe.xmp"] = re.sub( - pattern, "", exif_image.info["XML:com.adobe.xmp"] - ) + for k in ("xmp", "XML:com.adobe.xmp"): + exif_image.info[k] = re.sub(pattern, "", exif_image.info[k]) 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..08899c83e6c 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -615,6 +615,8 @@ def chunk_iTXt(self, pos: int, length: int) -> bytes: return s self.im_info[k] = self.im_text[k] = iTXt(v, lang, tk) + if k == "XML:com.adobe.xmp": + self.im_info["xmp"] = self.im_info[k] self.check_text_memory(len(v)) return s @@ -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