diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 4a4826d22e3..9557e02fd61 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -484,6 +484,56 @@ def check(orientation_im: Image.Image) -> None: assert 0x0112 not in transposed_im.getexif() +@pytest.mark.parametrize("info_key", ["XML:com.adobe.xmp", "xmp"]) +@pytest.mark.parametrize("in_place", [False, True]) +@pytest.mark.parametrize( + "orientation", + [ + 'tiff:Orientation="6"', + "tiff:Orientation='6'", + 'tiff:Orientation = "6"', + "tiff:Orientation\n=\t'6'", + 'tiff:Orientation=" 6 "', + "6", + "\n 6\t", + "6", + ], +) +def test_exif_transpose_xmp_xml_syntax( + info_key: str, in_place: bool, orientation: str +) -> None: + im = Image.new("RGB", (2, 1)) + im.putpixel((0, 0), (255, 0, 0)) + im.putpixel((1, 0), (0, 0, 255)) + expected = im.transpose(Image.Transpose.ROTATE_270) + namespaces = ( + 'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" ' + 'xmlns:tiff="http://ns.adobe.com/tiff/1.0/"' + ) + if orientation.startswith("<"): + xmp = ( + f"" + f"{orientation}" + ) + else: + xmp = f"" + im.info[info_key] = xmp if info_key == "XML:com.adobe.xmp" else xmp.encode() + + assert im.getexif()[0x0112] == 6 + if in_place: + ImageOps.exif_transpose(im, in_place=True) + transposed = im + else: + transposed = ImageOps.exif_transpose(im) + assert im.size == (2, 1) + assert im.getexif()[0x0112] == 6 + assert_image_equal(transposed, expected) + assert "tiff:Orientation" not in str(transposed.info[info_key]) + transposed._reload_exif() + assert 0x0112 not in transposed.getexif() + assert_image_equal(ImageOps.exif_transpose(transposed), expected) + + def test_exif_transpose_with_xmp_tuple() -> None: with Image.open("Tests/images/xmp_tags_orientation.png") as im: assert im.getexif()[0x0112] == 3 diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 5ec4c3d1485..fbb892999b3 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -99,6 +99,15 @@ intended for internal use, so there is no replacement. API changes =========== +XMP orientation whitespace and quotation marks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.getexif` now reads XMP orientation attributes +with single quotes or whitespace around the equals sign and orientation value. +Whitespace around an orientation element's value is also supported. +:py:func:`~PIL.ImageOps.exif_transpose` removes these forms after transposition, +so reloading the metadata does not cause the image to be transposed again. + TODO ^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 4bb7c51aaad..dcaba62b9bc 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1666,13 +1666,13 @@ def getexif(self) -> Exif: # 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])' + pattern: str | bytes = r"tiff:Orientation\s*(?:=\s*[\"']\s*|>\s*)([0-9])" if not xmp_tags and (xmp_tags := self.info.get("xmp")): - pattern = rb'tiff:Orientation(="|>)([0-9])' + pattern = rb"tiff:Orientation\s*(?:=\s*[\"']\s*|>\s*)([0-9])" if xmp_tags: match = re.search(pattern, xmp_tags) if match: - self._exif[ExifTags.Base.Orientation] = int(match[2]) + self._exif[ExifTags.Base.Orientation] = int(match[1]) return self._exif diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 593f801b031..6407e3b579f 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -750,8 +750,8 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image for key in ("XML:com.adobe.xmp", "xmp"): if key in exif_image.info: for pattern in ( - r'tiff:Orientation="([0-9])"', - r"([0-9])", + r"tiff:Orientation\s*=\s*([\"'])\s*[0-9]\s*\1", + r"\s*[0-9]\s*", ): value = exif_image.info[key] if isinstance(value, str):