Repository: python-pillow/Pillow

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

Discussion:
@radarhere: I've created #8792 to resolve this.

Solution patch:
diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt
index 10e59b885..2e3610478 100644
--- a/.ci/requirements-mypy.txt
+++ b/.ci/requirements-mypy.txt
@@ -1,4 +1,4 @@
-mypy==1.14.1
+mypy==1.15.0
 IceSpringPySideStubs-PyQt6
 IceSpringPySideStubs-PySide6
 ipython
diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml
index 045926482..bb6d7dc37 100644
--- a/.github/workflows/test-mingw.yml
+++ b/.github/workflows/test-mingw.yml
@@ -60,6 +60,7 @@ jobs:
               mingw-w64-x86_64-gcc \
               mingw-w64-x86_64-ghostscript \
               mingw-w64-x86_64-lcms2 \
+              mingw-w64-x86_64-libimagequant \
               mingw-w64-x86_64-libjpeg-turbo \
               mingw-w64-x86_64-libraqm \
               mingw-w64-x86_64-libtiff \
diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh
index f0c96d160..278c13be6 100755
--- a/.github/workflows/wheels-dependencies.sh
+++ b/.github/workflows/wheels-dependencies.sh
@@ -39,12 +39,12 @@ ARCHIVE_SDIR=pillow-depends-main
 # Package versions for fresh source builds
 FREETYPE_VERSION=2.13.3
 HARFBUZZ_VERSION=10.2.0
-LIBPNG_VERSION=1.6.46
+LIBPNG_VERSION=1.6.47
 JPEGTURBO_VERSION=3.1.0
 OPENJPEG_VERSION=2.5.3
 XZ_VERSION=5.6.4
 TIFF_VERSION=4.6.0
-LCMS2_VERSION=2.16
+LCMS2_VERSION=2.17
 ZLIB_NG_VERSION=2.2.4
 LIBWEBP_VERSION=1.5.0
 BZIP2_VERSION=1.0.8
diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst
index 46a4c1245..b400a3436 100644
--- a/docs/installation/building-from-source.rst
+++ b/docs/installation/building-from-source.rst
@@ -51,7 +51,7 @@ Many of Pillow's features require external libraries:
 * **littlecms** provides color management
 
   * Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and
-    above uses liblcms2. Tested with **1.19** and **2.7-2.16**.
+    above uses liblcms2. Tested with **1.19** and **2.7-2.17**.
 
 * **libwebp** provides the WebP format.
 
diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py
index 26e5bd4a6..d60e75bb6 100644
--- a/src/PIL/FtexImagePlugin.py
+++ b/src/PIL/FtexImagePlugin.py
@@ -79,8 +79,6 @@ class FtexImageFile(ImageFile.ImageFile):
         self._size = struct.unpack("<2i", self.fp.read(8))
         mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
 
-        self._mode = "RGB"
-
         # Only support single-format files.
         # I don't know of any multi-format file.
         assert format_count == 1
@@ -95,6 +93,7 @@ class FtexImageFile(ImageFile.ImageFile):
             self._mode = "RGBA"
             self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
         elif format == Format.UNCOMPRESSED:
+            self._mode = "RGB"
             self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
         else:
             msg = f"Invalid texture compression format: {repr(format)}"
diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py
index fc4801e9d..891225ce2 100644
--- a/src/PIL/GdImageFile.py
+++ b/src/PIL/GdImageFile.py
@@ -56,7 +56,7 @@ class GdImageFile(ImageFile.ImageFile):
             msg = "Not a valid GD 2.x .gd file"
             raise SyntaxError(msg)
 
-        self._mode = "L"  # FIXME: "P"
+        self._mode = "P"
         self._size = i16(s, 2), i16(s, 4)
 
         true_color = s[6]
@@ -68,14 +68,14 @@ class GdImageFile(ImageFile.ImageFile):
             self.info["transparency"] = tindex
 
         self.palette = ImagePalette.raw(
-            "XBGR", s[7 + true_color_offset + 4 : 7 + true_color_offset + 4 + 256 * 4]
+            "RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
         )
 
         self.tile = [
             ImageFile._Tile(
                 "raw",
                 (0, 0) + self.size,
-                7 + true_color_offset + 4 + 256 * 4,
+                7 + true_color_offset + 6 + 256 * 4,
                 "L",
             )
         ]
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
index 742b5f587..c4ebc5931 100644
--- a/src/PIL/ImageDraw.py
+++ b/src/PIL/ImageDraw.py
@@ -42,11 +42,7 @@ from ._deprecate import deprecate
 from ._typing import Coords
 
 # experimental access to the outline API
-Outline: Callable[[], Image.core._Outline] | None
-try:
-    Outline = Image.core.outline
-except AttributeError:
-    Outline = None
+Outline: Callable[[], Image.core._Outline] = Image.core.outline
 
 if TYPE_CHECKING:
     from . import ImageDraw2, ImageFont
diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py
index fef1d7328..75dfbee22 100644
--- 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:
diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py
index bf29fdba5..e6a9d8eea 100644
--- a/src/PIL/ImageTk.py
+++ b/src/PIL/ImageTk.py
@@ -28,7 +28,7 @@ from __future__ import annotations
 
 import tkinter
 from io import BytesIO
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any
 
 from . import Image, ImageFile
 
@@ -263,28 +263,3 @@ def getimage(photo: PhotoImage) -> Image.Image:
     _pyimagingtkcall("PyImagingPhotoGet", photo, im.getim())
 
     return im
-
-
-def _show(image: Image.Image, title: str | None) -> None:
-    """Helper for the Image.show method."""
-
-    class UI(tkinter.Label):
-        def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None:
-            self.image: BitmapImage | PhotoImage
-            if im.mode == "1":
-                self.image = BitmapImage(im, foreground="white", master=master)
-            else:
-                self.image = PhotoImage(im, master=master)
-            if TYPE_CHECKING:
-                image = cast(tkinter._Image, self.image)
-            else:
-                image = self.image
-            super().__init__(master, image=image, bg="black", bd=0)
-
-    if not getattr(tkinter, "_default_root"):
-        msg = "tkinter not initialized"
-        raise OSError(msg)
-    top = tkinter.Toplevel()
-    if title:
-        top.title(title)
-    UI(top, image).pack()
diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py
index bbddd972e..9ce38c427 100644
--- a/src/PIL/MicImagePlugin.py
+++ b/src/PIL/MicImagePlugin.py
@@ -73,12 +73,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
     def seek(self, frame: int) -> None:
         if not self._seek_check(frame):
             return
-        try:
-            filename = self.images[frame]
-        except IndexError as e:
-            msg = "no such frame"
-            raise EOFError(msg) from e
-
+        filename = self.images[frame]
         self.fp = self.ole.openstream(filename)
 
         TiffImagePlugin.TiffImageFile._open(self)
diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py
index c59d302e5..0aada8a06 100644
--- a/src/PIL/PsdImagePlugin.py
+++ b/src/PIL/PsdImagePlugin.py
@@ -169,15 +169,11 @@ class PsdImageFile(ImageFile.ImageFile):
             return
 
         # seek to given layer (1..max)
-        try:
-            _, mode, _, tile = self.layers[layer - 1]
-            self._mode = mode
-            self.tile = tile
-            self.frame = layer
-            self.fp = self._fp
-        except IndexError as e:
-            msg = "no such layer"
-            raise EOFError(msg) from e
+        _, mode, _, tile = self.layers[layer - 1]
+        self._mode = mode
+        self.tile = tile
+        self.frame = layer
+        self.fp = self._fp
 
     def tell(self) -> int:
         # return layer number (0=image, 1..max=layers)
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
index 0454038e8..3d36d1abc 100644
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -404,7 +404,7 @@ class IFDRational(Rational):
     def __repr__(self) -> str:
         return str(float(self._val))
 
-    def __hash__(self) -> int:
+    def __hash__(self) -> int:  # type: ignore[override]
         return self._val.__hash__()
 
     def __eq__(self, other: object) -> bool:
diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py
index f942716cb..d26d26b6a 100644
--- a/winbuild/build_prepare.py
+++ b/winbuild/build_prepare.py
@@ -115,9 +115,9 @@ V = {
     "FRIBIDI": "1.0.16",
     "HARFBUZZ": "10.2.0",
     "JPEGTURBO": "3.1.0",
-    "LCMS2": "2.16",
+    "LCMS2": "2.17",
     "LIBIMAGEQUANT": "4.3.4",
-    "LIBPNG": "1.6.46",
+    "LIBPNG": "1.6.47",
     "LIBWEBP": "1.5.0",
     "OPENJPEG": "2.5.3",
     "TIFF": "4.6.0",


Test patch:
diff --git a/Tests/test_file_ftex.py b/Tests/test_file_ftex.py
index 0c544245a..fdd7b3757 100644
--- a/Tests/test_file_ftex.py
+++ b/Tests/test_file_ftex.py
@@ -1,5 +1,8 @@
 from __future__ import annotations
 
+import io
+import struct
+
 import pytest
 
 from PIL import FtexImagePlugin, Image
@@ -23,3 +26,15 @@ def test_invalid_file() -> None:
 
     with pytest.raises(SyntaxError):
         FtexImagePlugin.FtexImageFile(invalid_file)
+
+
+def test_invalid_texture() -> None:
+    with open("Tests/images/ftex_dxt1.ftc", "rb") as fp:
+        data = fp.read()
+
+    # Change texture compression format
+    data = data[:24] + struct.pack("<i", 2) + data[28:]
+
+    with pytest.raises(ValueError, match="Invalid texture compression format: 2"):
+        with Image.open(io.BytesIO(data)):
+            pass
diff --git a/Tests/test_file_gd.py b/Tests/test_file_gd.py
index d512df284..806532c17 100644
--- a/Tests/test_file_gd.py
+++ b/Tests/test_file_gd.py
@@ -4,6 +4,8 @@ import pytest
 
 from PIL import GdImageFile, UnidentifiedImageError
 
+from .helper import assert_image_similar_tofile
+
 TEST_GD_FILE = "Tests/images/hopper.gd"
 
 
@@ -11,6 +13,7 @@ def test_sanity() -> None:
     with GdImageFile.open(TEST_GD_FILE) as im:
         assert im.size == (128, 128)
         assert im.format == "GD"
+        assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.jpg", 14)
 
 
 def test_bad_mode() -> None:
diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py
index d127175eb..232cbb16c 100644
--- a/Tests/test_imagedraw.py
+++ b/Tests/test_imagedraw.py
@@ -448,7 +448,6 @@ def test_shape1() -> None:
     x3, y3 = 95, 5
 
     # Act
-    assert ImageDraw.Outline is not None
     s = ImageDraw.Outline()
     s.move(x0, y0)
     s.curve(x1, y1, x2, y2, x3, y3)
@@ -470,7 +469,6 @@ def test_shape2() -> None:
     x3, y3 = 5, 95
 
     # Act
-    assert ImageDraw.Outline is not None
     s = ImageDraw.Outline()
     s.move(x0, y0)
     s.curve(x1, y1, x2, y2, x3, y3)
@@ -489,7 +487,6 @@ def test_transform() -> None:
     draw = ImageDraw.Draw(im)
 
     # Act
-    assert ImageDraw.Outline is not None
     s = ImageDraw.Outline()
     s.line(0, 0)
     s.transform((0, 0, 0, 0, 0, 0))
@@ -1526,7 +1523,6 @@ def test_same_color_outline(bbox: Coords) -> None:
     x2, y2 = 95, 50
     x3, y3 = 95, 5
 
-    assert ImageDraw.Outline is not None
     s = ImageDraw.Outline()
     s.move(x0, y0)
     s.curve(x1, y1, x2, y2, x3, y3)
diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py
index 3621aa50f..9f2fd5ba2 100644
--- a/Tests/test_imageops.py
+++ b/Tests/test_imageops.py
@@ -448,6 +448,15 @@ def test_exif_transpose() -> 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