Repository: python-pillow/Pillow

Issue:
Pillow 11.1.0 not reading JPEG comment
### What did you do?

Loaded the attached image using the provided code.

### What did you expect to happen?

As documented in the [Image file formats](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) page, there should be a `comment` field in the `info` dict containing the same data as this exiftool output:

```
User Comment                    : {"prompt": "a photograph of an astronaut riding a horse", "negative_prompt": "", "seed": 0, "use_stable_diffusion_model": "sd-v1-5", "clip_skip": false, "use_controlnet_model": null, "control_alpha": null, "use_vae_model": "", "sampler_name": "euler_a", "width": 512, "height": 768, "num_inference_steps": 25, "guidance_scale": 7.5, "use_lora_model": null, "use_embeddings_model": null, "tiling": null, "use_face_correction": null, "use_upscale": null}
```

### What actually happened?

The comment field is missing.

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

* OS: Kubuntu Linux 22.04 LTS
* Python: 3.10.12
* Pillow: 11.1.0

```text
--------------------------------------------------------------------
Pillow 11.1.0
Python 3.10.12 (main, Feb  4 2025, 14:57:36) [GCC 11.4.0]
--------------------------------------------------------------------
Python executable is [REDACTED]/.venv/bin/python3
Environment Python files loaded from [REDACTED]/.venv
System Python files loaded from /usr
--------------------------------------------------------------------
Python Pillow modules loaded from [REDACTED]/.venv/lib/python3.10/site-packages/PIL
Binary Pillow modules loaded from [REDACTED]/.venv/lib/python3.10/site-packages/PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 11.1.0
--- TKINTER support ok, loaded 8.6
--- 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.2.11, 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.8, harfbuzz 10.1.0
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------
```

```python
from PIL import Image
im = Image.open('a_photograph_of_an_astronaut_riding_a_horse_4MZPQQN0.jpeg')
print(im.info)
```

![JPEG generated using Easy Diffusion with embedded metadata](https://github.com/user-attachments/assets/97b1000a-ec13-4669-85d0-d9ac75322720)

Discussion:
@radarhere: Hi. JpegImagePlugin's `info["comment"]` retrieves data from the COM marker. See https://en.wikipedia.org/wiki/JPEG#cite_ref-51

Instead, the comment in your image is actually contained within the EXIF data, under the UserComment tag. See https://web.archive.org/web/20240329145259/https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/usercomment.html

This should print what you're after.
```python
from PIL import Image, ExifTags
im = Image.open('a_photograph_of_an_astronaut_riding_a_horse_4MZPQQN0.jpeg')
exif = im.getexif()
ifd = exif.get_ifd(ExifTags.IFD.Exif)
comment = ifd[ExifTags.Base.UserComment]
print(comment.decode())
```@ssokolow: 1. Perhaps there should be a note in the docs to that effect.
2. Pydantic rejects the resulting JSON because, for some reason, `comment.decode()` prepends `UNICODE` to the output.

**EDIT**: Never mind. I'm just converting it wrong. `repr()` shows there are also a bunch of nulls, so it's clear I need to `dir()` around.

This is the `repr()` of what `comment.decode()` is producing:

```
'UNICODE\x00\x00{\x00"\x00p\x00r\x00o\x00m\x00p\x00t\x00"\x00:\x00 \x00"\x00a\x00 \x00p\x00h\x00o\x00t\x00o\x00g\x00r\x00a\x00p\x00h\x00 \x00o\x00f\x00 \x00a\x00n\x00 \x00a\x00s\x00t\x00r\x00o\x00n\x00a\x00u\x00t\x00 \x00r\x00i\x00d\x00i\x00n\x00g\x00 \x00a\x00 \x00h\x00o\x00r\x00s\x00e\x00"\x00,\x00 \x00"\x00n\x00e\x00g\x00a\x00t\x00i\x00v\x00e\x00_\x00p\x00r\x00o\x00m\x00p\x00t\x00"\x00:\x00 \x00"\x00"\x00,\x00 \x00"\x00s\x00e\x00e\x00d\x00"\x00:\x00 \x000\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00s\x00t\x00a\x00b\x00l\x00e\x00_\x00d\x00i\x00f\x00f\x00u\x00s\x00i\x00o\x00n\x00_\x00m\x00o\x00d\x00e\x00l\x00"\x00:\x00 \x00"\x00s\x00d\x00-\x00v\x001\x00-\x005\x00"\x00,\x00 \x00"\x00c\x00l\x00i\x00p\x00_\x00s\x00k\x00i\x00p\x00"\x00:\x00 \x00f\x00a\x00l\x00s\x00e\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00c\x00o\x00n\x00t\x00r\x00o\x00l\x00n\x00e\x00t\x00_\x00m\x00o\x00d\x00e\x00l\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00,\x00 \x00"\x00c\x00o\x00n\x00t\x00r\x00o\x00l\x00_\x00a\x00l\x00p\x00h\x00a\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00v\x00a\x00e\x00_\x00m\x00o\x00d\x00e\x00l\x00"\x00:\x00 \x00"\x00"\x00,\x00 \x00"\x00s\x00a\x00m\x00p\x00l\x00e\x00r\x00_\x00n\x00a\x00m\x00e\x00"\x00:\x00 \x00"\x00e\x00u\x00l\x00e\x00r\x00_\x00a\x00"\x00,\x00 \x00"\x00w\x00i\x00d\x00t\x00h\x00"\x00:\x00 \x005\x001\x002\x00,\x00 \x00"\x00h\x00e\x00i\x00g\x00h\x00t\x00"\x00:\x00 \x007\x006\x008\x00,\x00 \x00"\x00n\x00u\x00m\x00_\x00i\x00n\x00f\x00e\x00r\x00e\x00n\x00c\x00e\x00_\x00s\x00t\x00e\x00p\x00s\x00"\x00:\x00 \x002\x005\x00,\x00 \x00"\x00g\x00u\x00i\x00d\x00a\x00n\x00c\x00e\x00_\x00s\x00c\x00a\x00l\x00e\x00"\x00:\x00 \x007\x00.\x005\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00l\x00o\x00r\x00a\x00_\x00m\x00o\x00d\x00e\x00l\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00e\x00m\x00b\x00e\x00d\x00d\x00i\x00n\x00g\x00s\x00_\x00m\x00o\x00d\x00e\x00l\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00,\x00 \x00"\x00t\x00i\x00l\x00i\x00n\x00g\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00f\x00a\x00c\x00e\x00_\x00c\x00o\x00r\x00r\x00e\x00c\x00t\x00i\x00o\x00n\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00,\x00 \x00"\x00u\x00s\x00e\x00_\x00u\x00p\x00s\x00c\x00a\x00l\x00e\x00"\x00:\x00 \x00n\x00u\x00l\x00l\x00}'
```@radarhere: If you're concerned about `\x00`, then this
```python
comment = ifd[ExifTags.Base.UserComment]
print(repr(comment.replace(b"\x00", b"").decode()))
```
will give
```
'UNICODE{"prompt": "a photograph of an astronaut riding a horse", "negative_prompt": "", "seed": 0, "use_stable_diffusion_model": "sd-v1-5", "clip_skip": false, "use_controlnet_model": null, "control_alpha": null, "use_vae_model": "", "sampler_name": "euler_a", "width": 512, "height": 768, "num_inference_steps": 25, "guidance_scale": 7.5, "use_lora_model": null, "use_embeddings_model": null, "tiling": null, "use_face_correction": null, "use_upscale": null}'
```@ssokolow: I'm concerned about receiving a mysteriously incompletely decoded blob of not-quite-JSON where I don't want to be responsible for anticipating all other possible forms it might take in edge cases in the wild.

The tool I'm writing is explicitly intended as a replacement for another tool which I keep running into papercut bugs in, and, at minimum, it would be a bad look if my own creation had papercut bugs because I chose the hacky solution.

If there's no `.actually_decode()` that I can call to foist getting it right off on Pillow, I might as well add `exifread` to my dependencies alongside Pillow, since I know it Just Works and gets it right for this use-case.

**EDIT:** Actually, now I'm wondering if it's not my fault but Pillow's that I'm having so much trouble figuing out which Pydantic method I need to call to ingest the form Easy Diffusion stores embedded prompt data in when using PNG instead of JPEG. Time for more `repr()`.@radarhere: I've created https://github.com/python-pillow/Pillow/pull/8788 to update the documentation to read
> A comment about the image, from the COM marker. This is separate from the UserComment tag that may be stored in the EXIF data.

Let us know if there is anything specific you would like further assistance with.@ssokolow: That works for the documentation.

I think the only other thing I'd like help with is understanding if that more or less undocumented `.get_ifd()` approach is a viable solution or not. (i.e. Whether there's a way to ask Pillow to not stop at what appears at first glance to be some kind of of binary blob consisting of a format-identifying ASCII prefix (`UNICODE`) followed by UTF-16 and decode all the way to a Python unicode string for me.)

(By more or less undocumented, I mean that it's apparently a young enough method for DuckDuckGo to not have picked it up yet, and Google takes me to docs where I slam face-first into an `Any` return type with no description.)@radarhere: As far as Pillow's documentation goes, I can offer
- https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Exif to explain the  use of `get_ifd()`
- https://pillow.readthedocs.io/en/stable/reference/ExifTags.html to explain the nature of ExifTags
- https://pillow.readthedocs.io/en/stable/_modules/PIL/ExifTags.html#Base to list the ExifTags

If you're talking about `get_ifd(tag: int) → dict[int, Any]`, yes, `get_ifd()` returns a dictionary mapping to integers to... anything, but to be fair, there are reasonable number of Exif tags, and there can even be custom Exif tags that may not be documented anywhere online.@ssokolow: ...so is my first impression correct that, for `ExifTags.Base.UserComment`, the current situation with Pillow is "Here's the binary blob it's storing. You're on your own beyond that."?

...because there's even a [Stack Overflow question](https://stackoverflow.com/questions/64311533/how-to-decode-a-exif-usercomment) that never received an answer from 4 years ago asking how to decode it with a reply that starts "Did you find anything? My quick search led to this: According to JEITA CP-3451, UserComment is rather special thing with a hairy character encoding hack in the first 8 bytes."

Not the kind of thing I'm willing to take responsibility for maintaining and getting right across all possible values when people apparently can't even link to the spec it's defined in. I have enough experience with that kind of self-describing any-codepage encoding nonsense with various standardized things stored in X11 XAtoms.@radarhere: Perhaps another way of looking at it would be that Pillow is providing the exact data that was requested. I think some users do want to access that kind of raw information.

First, I'm not sure where a specific method for parsing a single EXIF tag should live in our API. I know the obvious answer might be to just store it in `info["comment"]`. Maybe.
Second, if there isn't any documentation available for the way that encoders write data to the UserComment tag, it's not clear how we could write Pillow code to parse that.@ssokolow: > Perhaps another way of looking at it would be that Pillow is providing the exact data that was requested. I think some users do want to access that kind of raw information.

True... but, in my case, what I'm looking for is an alternative to having to depend on both Pillow and exifread, just so I can extract the JSON from both PNG and JPEG files without having to write my own decoder.

> Second, if there isn't any documentation available for the way that encoders write data to the UserComment tag, it's not clear how we could write Pillow code to parse that.

It's less that it's undocumented ([here you go](https://home.jeita.or.jp/tsc/std-pdf/CP3451C.pdf#page=52&zoom=auto,-18,456)) and more that I don't want to maintain an implementation of that sort of thing. (I've been a little once-burned, twice-shy ever since running into X11's [Compound Text Encoding](https://www.x.org/releases/X11R7.7-RC1/doc/xorg-docs/ctext/ctext.html)... thankfully, it seems so dead that even X.org utilities just assume it'll be a single blob of latin1 these days.)@ssokolow: Ugh. My own testing shows that exifread is [broken in the presence of non-ASCII text](https://github.com/ianare/exif-py/issues/206) and a quick look at their source suggests it blows up when it encounters things like CJK text because they implemented the `comment.replace(b"\x00", b"")` you suggested, causing their UTF-8 decoder to barf when encountering a subsequence of UTF-16 low bytes from codepoints beyond the 0-127 ASCII compatibility range.

I guess I have no choice but to implement the `UNICODE` variant of EXIF UserComment on top of Pillow, commit a bunch of test fixtures, and hope there are no edge cases I'm missing where Easy Diffusion can be induced to emit the `ASCII\0\0\0`, `JIS\0\0\0\0\0`, or Undefined (all NULLs) variants. (Undefined is literally spec'd as "use the system codepage and hope for the best".)

(Which is annoying, because I've already discovered it apparently contains three independent JSON serializers with different quirks for the three different ways to ask it to dump the prompting data to `.json` files. (Autosave, single image download, and download all.) If it's also doing that for embedding into `.jpeg`, `.png`, and `.webp`, that'll be a combinatorial explosion of unit tests.)

**EDIT:** It looks like the equally hacky implementation that works as a replacement for `comment.replace(b"\x00", b"").decode()` when the encoding tag is `UNICODE\0` is `comment[8:].decode('utf-16be')`. At least, it passes Pydantic validation and the correct text is in the fields where I put some CJK characters.@radarhere: https://github.com/python-pillow/Pillow/pull/8788 has now been merged to update the documentation.

It sounds like you've found your way to an answer. Is there anything further?@ssokolow: Nothing on Pillow's end. Thanks. :)

(I do, however, have some choice profanities for whoever designed Easy Diffusion's encoding and structuring scheme for serializing metadata into PNG files.)

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/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index db8e4d58b..1fe6badae 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -63,7 +63,7 @@ jobs:
           - name: "macOS 10.15 x86_64"
             os: macos-13
             cibw_arch: x86_64
-            build: "pp310*"
+            build: "pp3*"
             macosx_deployment_target: "10.15"
           - name: "macOS arm64"
             os: macos-latest
diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index a915ee4e2..991cadaa2 100644
--- a/docs/handbook/image-file-formats.rst
+++ b/docs/handbook/image-file-formats.rst
@@ -454,7 +454,8 @@ The :py:meth:`~PIL.Image.open` method may set the following
     Raw EXIF data from the image.
 
 **comment**
-    A comment about the image.
+    A comment about the image, from the COM marker. This is separate from the
+    UserComment tag that may be stored in the EXIF data.
 
     .. versionadded:: 7.1.0
 
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