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):