Repository: python-pillow/Pillow

Problem statement:
XMP <?xpacket> wrapper tags causes exception in ImageOps.exif_transpose()
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do?
When converting a TIF to a JPG, if the image contains XMP data that uses a <?xpacket> wrapper, conversion fails with an exception bubbled up from re module. 

### What did you expect to happen?
Conversion should happen

### What actually happened?
Exception generated

```pytb
TypeError ("expected string or bytes-like object, got 'tuple'",)
Traceback (most recent call last):
  File "<ipython-input-64-92aa2f62a20c>", line 5, in <module>
    jpeg_image = img.convert("RGB")
                 ^^^^^^^^^^^^^^^^^^
  File "/Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL/Image.py", line 984, in convert
    self.load()
  File "/Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL/TiffImagePlugin.py", line 1292, in load
    return self._load_libtiff()
           ^^^^^^^^^^^^^^^^^^^^
  File "/Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL/TiffImagePlugin.py", line 1402, in _load_libtiff
    self.load_end()
  File "/Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL/TiffImagePlugin.py", line 1314, in load_end
    ImageOps.exif_transpose(self, in_place=True)
  File "/Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL/ImageOps.py", line 725, in exif_transpose
    else re.sub(pattern.encode(), b"", value)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.8/Frameworks/Python.framework/Versions/3.12/lib/python3.12/re/__init__.py", line 186, in sub
    return _compile(pattern, flags).sub(repl, string, count)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: expected string or bytes-like object, got 'tuple'
```

### What are your OS, Python and Pillow versions?

* OS:  MacOS Sequoia
* Python: 3.12.8 
* Pillow:  11.1.0 (via PIL.__version__)

```text
% pipenv run python3 -m PIL.report
--------------------------------------------------------------------
Pillow 11.1.0
Python 3.12.8 (main, Dec  3 2024, 18:42:41) [Clang 16.0.0 (clang-1600.0.26.4)]
--------------------------------------------------------------------
Python executable is /Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/bin/python3
Environment Python files loaded from /Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK
System Python files loaded from /opt/homebrew/opt/python@3.12/Frameworks/Python.framework/Versions/3.12
--------------------------------------------------------------------
Python Pillow modules loaded from /Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL
Binary Pillow modules loaded from /Users/graeme/.local/share/virtualenvs/sandbox-0NfWatfK/lib/python3.12/site-packages/PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 11.1.0
*** TKINTER support not installed
--- FREETYPE2 support ok, loaded 2.13.2
--- LITTLECMS2 support ok, loaded 2.16
--- WEBP support ok, loaded 1.5.0
--- JPEG support ok, compiled for libjpeg-turbo 3.1.0
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.3
--- ZLIB (PNG/ZIP) support ok, loaded 1.3.1.zlib-ng, compiled for zlib-ng 2.2.2
--- LIBTIFF support ok, loaded 4.6.0
--- RAQM (Bidirectional Text) support ok, loaded 0.10.1, fribidi 1.0.16, harfbuzz 10.1.0
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------
```

### Reproduction steps
**I encountered this issue on a Mac. I've been struggling to isolate a more independant example. It's probably me struggling with exiftool**

I can't attach tif files. Please obtain from this [Google Drive folder](https://drive.google.com/drive/folders/184vVENhH-GMlgZx_I5GMou15iX7miW_G?usp=drive_link) 

1. Download no_xmp.tif and xmp.tif and run the following

```python
from PIL import Image
img = Image.open('xmp.tif')
new_img = img.convert("RGB")
```

Conversion should be successful

2. Repeat with xmp_xpacket.tif. Exception will be produced

3. We can break out the specific logic causing the exception from ImageOps.exif_transpose()

```python
def test_xmp(img):
    if 'xmp' in img.info:
        problematic_value = img.info['xmp']
        pattern = r'tiff:Orientation="([0-9])'
        if isinstance(problematic_value, str):
            re.sub(pattern, "", problematic_value)
        else:
            if isinstance(problematic_value, tuple):
                print("Tuple found. Not good")
            else:
                print("No tuple found. Good")            
                re.sub(pattern.encode(), b"", problematic_value)
    else:
        print("Didn't find xmp info")

img_noxmp = Image.open("no_xmp.tif")
test_xml(img_noxmp)
```

Will return "Didn't find xmp info"

```python
img_xmp = Image.open("xmp.tif")
test_xml(img_xmp)
```

Will return "No tuple found. Good"

Finally

```python
img_xpacket = Image.open("xmp_xpacket.tif")
test_xml(img_xpacket)
```

Will return "Tuple found. Not good"


4. To preview the XMP in these images run `exiftool -XMP -b xmp_xpacket.tif` and you'll observe the <?xpacket> field


### How did xpacket get in there?
I discovered that this is due to image rotation on a Mac. Specifically Quick Actions vs Preview app

1. Start with no_xmp.tif. Run `exiftool -XMP -b no_xmp.tif`
2.  Open 'Preview' and rotate the image (cmd-L for shortcut)
3. Run exiftool again and observe some XMP data has been added to the image
4. In the Mac finder, select 'no_xmp.tif' and either
    - Tap space to open quicklook and press cmd-L, or click the 'rotate left' button
    -  or right click on the image and select "Quick Actions -> Rotate left'

This will insert the xpacket tag

### Summary
So one could say there's an inconsistency in Mac on how XMP fragments are added depending on how you rotate the image.

Looking at part 1 of the [XMP spec](https://www.adobe.com/devnet/xmp.html), specifically section 7.3.2 it does appear that the XMP is valid if those xpacket wrappers are present. 

I suspect that  ImageOps.exif_transpose() needs to be adjusted


Hints:

Patch:
diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py
--- a/src/PIL/ImageOps.py
+++ b/src/PIL/ImageOps.py
@@ -729,11 +729,15 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image
                         r"<tiff:Orientation>([0-9])</tiff:Orientation>",
                     ):
                         value = exif_image.info[key]
-                        exif_image.info[key] = (
-                            re.sub(pattern, "", value)
-                            if isinstance(value, str)
-                            else re.sub(pattern.encode(), b"", value)
-                        )
+                        if isinstance(value, str):
+                            value = re.sub(pattern, "", value)
+                        elif isinstance(value, tuple):
+                            value = tuple(
+                                re.sub(pattern.encode(), b"", v) for v in value
+                            )
+                        else:
+                            value = re.sub(pattern.encode(), b"", value)
+                        exif_image.info[key] = value
         if not in_place:
             return transposed_image
     elif not in_place:


Test patch:
diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py
--- a/Tests/test_imageops.py
+++ b/Tests/test_imageops.py
@@ -448,6 +448,15 @@ def check(orientation_im: Image.Image) -> None:
     assert 0x0112 not in transposed_im.getexif()
 
 
+def test_exif_transpose_with_xmp_tuple() -> None:
+    with Image.open("Tests/images/xmp_tags_orientation.png") as im:
+        assert im.getexif()[0x0112] == 3
+
+        im.info["xmp"] = (b"test",)
+        transposed_im = ImageOps.exif_transpose(im)
+        assert 0x0112 not in transposed_im.getexif()
+
+
 def test_exif_transpose_xml_without_xmp() -> None:
     with Image.open("Tests/images/xmp_tags_orientation.png") as im:
         assert im.getexif()[0x0112] == 3