From 32a6c2268fd28c105e7d8cc9474ead637ee4283f Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 17:44:57 -0600
Subject: [PATCH 01/53] did not work with all .cur files

---
 src/PIL/BmpImagePlugin.py | 5 ++++-
 src/PIL/CurImagePlugin.py | 1 +
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index 1041ab763d2..aba7d9e22a7 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -209,7 +209,10 @@ def _bitmap(self, header=0, offset=0):
             else:
                 raise OSError("Unsupported BMP bitfields layout")
         elif file_info["compression"] == self.RAW:
-            if file_info["bits"] == 32 and header == 22:  # 32-bit .cur offset
+            try: self.is_cur
+            except: self.is_cur = False
+
+            if file_info["bits"] == 32 and self.is_cur:
                 raw_mode, self.mode = "BGRA", "RGBA"
         elif file_info["compression"] == self.RLE8:
             decoder_name = "bmp_rle"
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 42af5cafcef..e96e18d16af 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -44,6 +44,7 @@ def _open(self):
         s = self.fp.read(6)
         if not _accept(s):
             raise SyntaxError("not a CUR file")
+        self.is_cur = True
 
         # pick the largest cursor in the file
         m = b""

From e28357ecec8345cde844e525c94848b3fc15e7d5 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Wed, 21 Sep 2022 23:59:27 +0000
Subject: [PATCH 02/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/BmpImagePlugin.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index aba7d9e22a7..592b301de51 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -209,8 +209,10 @@ def _bitmap(self, header=0, offset=0):
             else:
                 raise OSError("Unsupported BMP bitfields layout")
         elif file_info["compression"] == self.RAW:
-            try: self.is_cur
-            except: self.is_cur = False
+            try:
+                self.is_cur
+            except:
+                self.is_cur = False
 
             if file_info["bits"] == 32 and self.is_cur:
                 raw_mode, self.mode = "BGRA", "RGBA"

From f4fde290704a988c51be36eb928de0b6434dd820 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 18:42:34 -0600
Subject: [PATCH 03/53] added ability to save as cursor

---
 src/PIL/BmpImagePlugin.py |   8 +-
 src/PIL/CurImagePlugin.py | 169 +++++++++++++++++++++++++++++++-------
 2 files changed, 141 insertions(+), 36 deletions(-)

diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index 592b301de51..e6a745eda64 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -209,13 +209,7 @@ def _bitmap(self, header=0, offset=0):
             else:
                 raise OSError("Unsupported BMP bitfields layout")
         elif file_info["compression"] == self.RAW:
-            try:
-                self.is_cur
-            except:
-                self.is_cur = False
-
-            if file_info["bits"] == 32 and self.is_cur:
-                raw_mode, self.mode = "BGRA", "RGBA"
+            pass
         elif file_info["compression"] == self.RLE8:
             decoder_name = "bmp_rle"
         else:
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index e96e18d16af..f7b73ff6bac 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -15,14 +15,103 @@
 #
 # See the README file for information on usage and redistribution.
 #
-from . import BmpImagePlugin, Image
+import warnings
+from io import BytesIO
+from math import ceil, log
+
+from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin, IcoImagePlugin
 from ._binary import i16le as i16
 from ._binary import i32le as i32
+from ._binary import o8
+from ._binary import o16le as o16
+from ._binary import o32le as o32
 
 #
 # --------------------------------------------------------------------
 
 
+def _save(im, fp, filename):
+    fp.write(b"\0\0\2\0")
+    bmp = im.encoderinfo.get("bitmap_format") == "bmp"
+    sizes = im.encoderinfo.get(
+        "sizes",
+        [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
+    )
+    hotspots = im.encoderinfo.get(
+        "hotspots",
+        [(0, 0) for i in range(len(sizes))]
+    )
+    if len(hotspots) != len(sizes):
+        raise ValueError("Number of hotspots must be equal to number of cursor sizes")
+
+    frames = []
+    provided_ims = [im] + im.encoderinfo.get("append_images", [])
+    width, height = im.size
+    for size in sorted(set(sizes)):
+        if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
+            continue
+
+        for provided_im in provided_ims:
+            if provided_im.size != size:
+                continue
+            frames.append(provided_im)
+            if bmp:
+                bits = BmpImagePlugin.SAVE[provided_im.mode][1]
+                bits_used = [bits]
+                for other_im in provided_ims:
+                    if other_im.size != size:
+                        continue
+                    bits = BmpImagePlugin.SAVE[other_im.mode][1]
+                    if bits not in bits_used:
+                        # Another image has been supplied for this size
+                        # with a different bit depth
+                        frames.append(other_im)
+                        bits_used.append(bits)
+            break
+        else:
+            # TODO: invent a more convenient method for proportional scalings
+            frame = provided_im.copy()
+            frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
+            frames.append(frame)
+    fp.write(o16(len(frames)))  # idCount(2)
+    offset = fp.tell() + len(frames) * 16
+    for hotspot, frame in zip(hotspots, frames):
+        width, height = frame.size
+        # 0 means 256
+        fp.write(o8(width if width < 256 else 0))  # bWidth(1)
+        fp.write(o8(height if height < 256 else 0))  # bHeight(1)
+
+        bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
+        fp.write(o8(colors))  # bColorCount(1)
+        fp.write(b"\0")  # bReserved(1)
+        fp.write(o16(hotspot[0]))  # x_hotspot(2)
+        fp.write(o16(hotspot[1]))  # y_hotspot(2)
+
+        image_io = BytesIO()
+        if bmp:
+            frame.save(image_io, "dib")
+
+            if bits != 32:
+                and_mask = Image.new("1", size)
+                ImageFile._save(
+                    and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
+                )
+        else:
+            frame.save(image_io, "png")
+        image_io.seek(0)
+        image_bytes = image_io.read()
+        if bmp:
+            image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
+        bytes_len = len(image_bytes)
+        fp.write(o32(bytes_len))  # dwBytesInRes(4)
+        fp.write(o32(offset))  # dwImageOffset(4)
+        current = fp.tell()
+        fp.seek(offset)
+        fp.write(image_bytes)
+        offset = offset + bytes_len
+        fp.seek(current)
+
+
 def _accept(prefix):
     return prefix[:4] == b"\0\0\2\0"
 
@@ -30,47 +119,69 @@ def _accept(prefix):
 ##
 # Image plugin for Windows Cursor files.
 
+class CurFile(IcoImagePlugin.IcoFile):
+    def __init__(self, buf):
+        """
+        Parse image from file-like object containing cur file data
+        """
 
-class CurImageFile(BmpImagePlugin.BmpImageFile):
+        # check if CUR
+        s = buf.read(6)
+        if not _accept(s):
+            raise SyntaxError("not a CUR file")
 
-    format = "CUR"
-    format_description = "Windows Cursor"
+        self.buf = buf
+        self.entry = []
 
-    def _open(self):
+        # Number of items in file
+        self.nb_items = i16(s, 4)
 
-        offset = self.fp.tell()
+        # Get headers for each item
+        for _ in range(self.nb_items):
+            s = buf.read(16)
 
-        # check magic
-        s = self.fp.read(6)
-        if not _accept(s):
-            raise SyntaxError("not a CUR file")
-        self.is_cur = True
+            icon_header = {
+                "width": s[0],
+                "height": s[1],
+                "nb_color": s[2],  # No. of colors in image (0 if >=8bpp)
+                "reserved": s[3],
+                "x_hotspot": i16(s, 4),
+                "y_hotspot": i16(s, 6),
+                "size": i32(s, 8),
+                "offset": i32(s, 12),
+            }
+
+            # See Wikipedia
+            for j in ("width", "height"):
+                if not icon_header[j]:
+                    icon_header[j] = 256
+
+            # cursor files have transparency, hence 32 bpp
+            icon_header["bpp"] = 32
+            icon_header["dim"] = (icon_header["width"], icon_header["height"])
+            icon_header["square"] = icon_header["width"] * icon_header["height"]
 
-        # pick the largest cursor in the file
-        m = b""
-        for i in range(i16(s, 4)):
-            s = self.fp.read(16)
-            if not m:
-                m = s
-            elif s[0] > m[0] and s[1] > m[1]:
-                m = s
-        if not m:
-            raise TypeError("No cursors were found")
+            self.entry.append(icon_header)
 
-        # load as bitmap
-        self._bitmap(i32(m, 12) + offset)
+        self.entry = sorted(self.entry, key=lambda x: x["square"])
+        self.entry.reverse()
 
-        # patch up the bitmap height
-        self._size = self.size[0], self.size[1] // 2
-        d, e, o, a = self.tile[0]
-        self.tile[0] = d, (0, 0) + self.size, o, a
 
-        return
+class CurImageFile(IcoImagePlugin.IcoImageFile):
+
+    format = "CUR"
+    format_description = "Windows Cursor"
+
+    def _open(self):
+        self.ico = CurFile(self.fp)
+        self.info["sizes"] = self.ico.sizes()
+        self.size = self.ico.entry[0]["dim"]
+        self.load()
 
 
 #
 # --------------------------------------------------------------------
 
 Image.register_open(CurImageFile.format, CurImageFile, _accept)
-
+Image.register_save(CurImageFile.format, _save)
 Image.register_extension(CurImageFile.format, ".cur")

From 95b8061e68ca4e4132c3e5964b252561d804396f Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 22 Sep 2022 00:43:16 +0000
Subject: [PATCH 04/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/CurImagePlugin.py | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index f7b73ff6bac..041ba22ebaa 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -19,7 +19,7 @@
 from io import BytesIO
 from math import ceil, log
 
-from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin, IcoImagePlugin
+from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile, PngImagePlugin
 from ._binary import i16le as i16
 from ._binary import i32le as i32
 from ._binary import o8
@@ -37,10 +37,7 @@ def _save(im, fp, filename):
         "sizes",
         [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
     )
-    hotspots = im.encoderinfo.get(
-        "hotspots",
-        [(0, 0) for i in range(len(sizes))]
-    )
+    hotspots = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(sizes))])
     if len(hotspots) != len(sizes):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
@@ -119,6 +116,7 @@ def _accept(prefix):
 ##
 # Image plugin for Windows Cursor files.
 
+
 class CurFile(IcoImagePlugin.IcoFile):
     def __init__(self, buf):
         """

From fbcf1b617a53ec1d5acf22eacde1c0b3d4d04b39 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 18:46:00 -0600
Subject: [PATCH 05/53] removed unused imports

---
 src/PIL/CurImagePlugin.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index f7b73ff6bac..0b17e5f9c0c 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -15,11 +15,9 @@
 #
 # See the README file for information on usage and redistribution.
 #
-import warnings
 from io import BytesIO
-from math import ceil, log
 
-from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin, IcoImagePlugin
+from . import BmpImagePlugin, Image, ImageFile, IcoImagePlugin
 from ._binary import i16le as i16
 from ._binary import i32le as i32
 from ._binary import o8

From 6a5878db12aa87b07d4dc265709a4784ce8368d5 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 18:47:00 -0600
Subject: [PATCH 06/53] reverted to original as change no longer necessary

---
 src/PIL/BmpImagePlugin.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index e6a745eda64..1041ab763d2 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -209,7 +209,8 @@ def _bitmap(self, header=0, offset=0):
             else:
                 raise OSError("Unsupported BMP bitfields layout")
         elif file_info["compression"] == self.RAW:
-            pass
+            if file_info["bits"] == 32 and header == 22:  # 32-bit .cur offset
+                raw_mode, self.mode = "BGRA", "RGBA"
         elif file_info["compression"] == self.RLE8:
             decoder_name = "bmp_rle"
         else:

From 2e08e8927c99f2e3a88e5b109ed4a6be9208c190 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 22 Sep 2022 00:48:13 +0000
Subject: [PATCH 07/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/CurImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 3bd854aa495..6039f27c340 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -17,7 +17,7 @@
 #
 from io import BytesIO
 
-from . import BmpImagePlugin, Image, ImageFile, IcoImagePlugin
+from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile
 from ._binary import i16le as i16
 from ._binary import i32le as i32
 from ._binary import o8

From 73e5955c90014e8d252aaf9a181bba6f14d5be1b Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 18:57:12 -0600
Subject: [PATCH 08/53] hotspots now accessible in info

---
 src/PIL/CurImagePlugin.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 3bd854aa495..5529fbc3671 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -162,6 +162,11 @@ def __init__(self, buf):
         self.entry = sorted(self.entry, key=lambda x: x["square"])
         self.entry.reverse()
 
+    def sizes(self):
+        return [(h["width"], h["height"]) for h in self.entry]
+
+    def hotspots(self):
+        return [(h["x_hotspot"], h["y_hotspot"]) for h in self.entry]
 
 class CurImageFile(IcoImagePlugin.IcoImageFile):
 
@@ -171,6 +176,7 @@ class CurImageFile(IcoImagePlugin.IcoImageFile):
     def _open(self):
         self.ico = CurFile(self.fp)
         self.info["sizes"] = self.ico.sizes()
+        self.info["hotspots"] = self.ico.hotspots()
         self.size = self.ico.entry[0]["dim"]
         self.load()
 

From 6f22968f7aad8d8180aba4d7fc59c53b16929e29 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 22 Sep 2022 00:57:54 +0000
Subject: [PATCH 09/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/CurImagePlugin.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 5d2ac7253d1..d66e4599338 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -168,6 +168,7 @@ def sizes(self):
     def hotspots(self):
         return [(h["x_hotspot"], h["y_hotspot"]) for h in self.entry]
 
+
 class CurImageFile(IcoImagePlugin.IcoImageFile):
 
     format = "CUR"

From 7585c21901304e4fb76649120e596d8bbdb6f28f Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 19:08:57 -0600
Subject: [PATCH 10/53] added exceptions to pass tests

---
 src/PIL/CurImagePlugin.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 5d2ac7253d1..7797e77618e 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -177,7 +177,10 @@ def _open(self):
         self.ico = CurFile(self.fp)
         self.info["sizes"] = self.ico.sizes()
         self.info["hotspots"] = self.ico.hotspots()
-        self.size = self.ico.entry[0]["dim"]
+        if len(self.ico.entry) > 0:
+            self.size = self.ico.entry[0]["dim"]
+        else:
+            raise TypeError("No cursors were found")
         self.load()
 
 

From 69b4ebf89b9b2c6a9a00efa72eb606068ff0a4e6 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Wed, 21 Sep 2022 22:28:13 -0600
Subject: [PATCH 11/53] Hopefully corrected bpp issue with older .cur

---
 src/PIL/CurImagePlugin.py | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 5062f666e93..dca7f7d5624 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -113,8 +113,6 @@ def _accept(prefix):
 
 ##
 # Image plugin for Windows Cursor files.
-
-
 class CurFile(IcoImagePlugin.IcoFile):
     def __init__(self, buf):
         """
@@ -152,11 +150,17 @@ def __init__(self, buf):
                 if not icon_header[j]:
                     icon_header[j] = 256
 
-            # cursor files have transparency, hence 32 bpp
-            icon_header["bpp"] = 32
+           
             icon_header["dim"] = (icon_header["width"], icon_header["height"])
             icon_header["square"] = icon_header["width"] * icon_header["height"]
 
+            # TODO: This needs further investigation. Cursor files do not really specify their bpp
+            # like ICO's as those bits are used for the y_hotspot. For now, bpp is calculated by
+            # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number 
+            # of pixels.
+            BITMAP_INFO_HEADER_SIZE = 40
+            icon_header["bpp"] = (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8 - (icon_header["square"]) // icon_header["square"]
+
             self.entry.append(icon_header)
 
         self.entry = sorted(self.entry, key=lambda x: x["square"])

From 1d846c280707a47c5a7b218e25dad62fb0f030a5 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 22 Sep 2022 04:28:59 +0000
Subject: [PATCH 12/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/CurImagePlugin.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index dca7f7d5624..29c6479ac50 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -150,16 +150,17 @@ def __init__(self, buf):
                 if not icon_header[j]:
                     icon_header[j] = 256
 
-           
             icon_header["dim"] = (icon_header["width"], icon_header["height"])
             icon_header["square"] = icon_header["width"] * icon_header["height"]
 
             # TODO: This needs further investigation. Cursor files do not really specify their bpp
             # like ICO's as those bits are used for the y_hotspot. For now, bpp is calculated by
-            # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number 
+            # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number
             # of pixels.
             BITMAP_INFO_HEADER_SIZE = 40
-            icon_header["bpp"] = (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8 - (icon_header["square"]) // icon_header["square"]
+            icon_header["bpp"] = (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8 - (
+                icon_header["square"]
+            ) // icon_header["square"]
 
             self.entry.append(icon_header)
 

From 3eda47268833c4937eb00d08fa687bc00bd7dad7 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Thu, 22 Sep 2022 19:03:39 -0600
Subject: [PATCH 13/53] added more cursors for testing

---
 Tests/images/cur/aero_arrow.cur        | Bin 0 -> 136606 bytes
 Tests/images/{ => cur}/deerstalker.cur | Bin
 Tests/images/{ => cur}/no_cursors.cur  | Bin
 Tests/images/cur/posy_link.cur         | Bin 0 -> 136606 bytes
 Tests/images/cur/posy_link.png         | Bin 0 -> 1893 bytes
 Tests/images/cur/stopwtch.cur          | Bin 0 -> 766 bytes
 Tests/images/cur/win98_arrow.cur       | Bin 0 -> 326 bytes
 7 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Tests/images/cur/aero_arrow.cur
 rename Tests/images/{ => cur}/deerstalker.cur (100%)
 rename Tests/images/{ => cur}/no_cursors.cur (100%)
 create mode 100644 Tests/images/cur/posy_link.cur
 create mode 100644 Tests/images/cur/posy_link.png
 create mode 100644 Tests/images/cur/stopwtch.cur
 create mode 100644 Tests/images/cur/win98_arrow.cur

diff --git a/Tests/images/cur/aero_arrow.cur b/Tests/images/cur/aero_arrow.cur
new file mode 100644
index 0000000000000000000000000000000000000000..7d58eec1285b63e3e638cbfff037b86eb2c0998e
GIT binary patch
diff --git a/Tests/images/deerstalker.cur b/Tests/images/cur/deerstalker.cur
similarity index 100%
rename from Tests/images/deerstalker.cur
rename to Tests/images/cur/deerstalker.cur
diff --git a/Tests/images/no_cursors.cur b/Tests/images/cur/no_cursors.cur
similarity index 100%
rename from Tests/images/no_cursors.cur
rename to Tests/images/cur/no_cursors.cur
diff --git a/Tests/images/cur/posy_link.cur b/Tests/images/cur/posy_link.cur
new file mode 100644
index 0000000000000000000000000000000000000000..e62300802558f2d02d1987b4141ec51b41adaaaf
GIT binary patch
diff --git a/Tests/images/cur/posy_link.png b/Tests/images/cur/posy_link.png
new file mode 100644
index 0000000000000000000000000000000000000000..65199b6d2d436d917ef8fa48d8f22d2fa705184c
GIT binary patch
diff --git a/Tests/images/cur/stopwtch.cur b/Tests/images/cur/stopwtch.cur
new file mode 100644
index 0000000000000000000000000000000000000000..858827ddbcb76f4d90acb3bc5a5bc9d4cfd79ed4
GIT binary patch
diff --git a/Tests/images/cur/win98_arrow.cur b/Tests/images/cur/win98_arrow.cur
new file mode 100644
index 0000000000000000000000000000000000000000..68c65b20ee7405ead4291a91b96ee929e69d52fe
GIT binary patch

From c74859d5377186fcb31f53e7f65704aa2cbd0752 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Thu, 22 Sep 2022 19:05:47 -0600
Subject: [PATCH 14/53] fixed bug with transparency

---
 src/PIL/CurImagePlugin.py | 48 +++++++++++++++------------------------
 1 file changed, 18 insertions(+), 30 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index dca7f7d5624..07dbe213a31 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -27,10 +27,11 @@
 #
 # --------------------------------------------------------------------
 
+_MAGIC = b"\0\0\2\0"
 
-def _save(im, fp, filename):
-    fp.write(b"\0\0\2\0")
-    bmp = im.encoderinfo.get("bitmap_format") == "bmp"
+def _save(im: Image.Image, fp: BytesIO, filename: str):
+    fp.write(_MAGIC)
+    bmp = im.encoderinfo.get("bitmap_format", "") == "bmp"
     sizes = im.encoderinfo.get(
         "sizes",
         [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
@@ -40,34 +41,16 @@ def _save(im, fp, filename):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
     frames = []
-    provided_ims = [im] + im.encoderinfo.get("append_images", [])
     width, height = im.size
     for size in sorted(set(sizes)):
         if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
             continue
 
-        for provided_im in provided_ims:
-            if provided_im.size != size:
-                continue
-            frames.append(provided_im)
-            if bmp:
-                bits = BmpImagePlugin.SAVE[provided_im.mode][1]
-                bits_used = [bits]
-                for other_im in provided_ims:
-                    if other_im.size != size:
-                        continue
-                    bits = BmpImagePlugin.SAVE[other_im.mode][1]
-                    if bits not in bits_used:
-                        # Another image has been supplied for this size
-                        # with a different bit depth
-                        frames.append(other_im)
-                        bits_used.append(bits)
-            break
-        else:
-            # TODO: invent a more convenient method for proportional scalings
-            frame = provided_im.copy()
-            frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
-            frames.append(frame)
+        # TODO: invent a more convenient method for proportional scalings
+        frame = im.copy()
+        frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
+        frames.append(frame)
+
     fp.write(o16(len(frames)))  # idCount(2)
     offset = fp.tell() + len(frames) * 16
     for hotspot, frame in zip(hotspots, frames):
@@ -108,13 +91,13 @@ def _save(im, fp, filename):
 
 
 def _accept(prefix):
-    return prefix[:4] == b"\0\0\2\0"
+    return prefix[:4] == _MAGIC
 
 
 ##
 # Image plugin for Windows Cursor files.
 class CurFile(IcoImagePlugin.IcoFile):
-    def __init__(self, buf):
+    def __init__(self, buf: BytesIO):
         """
         Parse image from file-like object containing cur file data
         """
@@ -157,9 +140,14 @@ def __init__(self, buf):
             # TODO: This needs further investigation. Cursor files do not really specify their bpp
             # like ICO's as those bits are used for the y_hotspot. For now, bpp is calculated by
             # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number 
-            # of pixels.
+            # of pixels. This seems to work well so far.
             BITMAP_INFO_HEADER_SIZE = 40
-            icon_header["bpp"] = (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8 - (icon_header["square"]) // icon_header["square"]
+            bpp_without_and = ((icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8) // icon_header["square"]
+
+            if bpp_without_and != 32:
+                icon_header["bpp"] = ((icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8 - icon_header["square"]) // icon_header["square"]
+            else:
+                icon_header["bpp"] = bpp_without_and
 
             self.entry.append(icon_header)
 

From 0b3bc198fb5f0b56f1ec8b12dc979ade908332f1 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Thu, 22 Sep 2022 19:06:07 -0600
Subject: [PATCH 15/53] added another test

---
 Tests/test_file_cur.py | 18 ++++++++++++------
 1 file changed, 12 insertions(+), 6 deletions(-)

diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index f04a20a220a..9ab1b4eeb30 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -2,11 +2,9 @@
 
 from PIL import CurImagePlugin, Image
 
-TEST_FILE = "Tests/images/deerstalker.cur"
 
-
-def test_sanity():
-    with Image.open(TEST_FILE) as im:
+def test_deerstalker():
+    with Image.open("Tests/images/cur/deerstalker.cur") as im:
         assert im.size == (32, 32)
         assert isinstance(im, CurImagePlugin.CurImageFile)
         # Check some pixel colors to ensure image is loaded properly
@@ -15,13 +13,21 @@ def test_sanity():
         assert im.getpixel((16, 16)) == (84, 87, 86, 255)
 
 
+def test_posy_link():
+    with Image.open("Tests/images/cur/posy_link.cur") as im:
+        assert im.size == (128, 128)
+        assert im.getpixel((0, 0)) == (0, 0, 0, 0)
+        assert im.getpixel((20, 20)) == (0, 0, 0, 255)
+        assert im.getpixel((40,40)) == (255, 255, 255, 255)
+
+
 def test_invalid_file():
-    invalid_file = "Tests/images/flower.jpg"
+    invalid_file = "Tests/images/cur/posy_link.png"
 
     with pytest.raises(SyntaxError):
         CurImagePlugin.CurImageFile(invalid_file)
 
-    no_cursors_file = "Tests/images/no_cursors.cur"
+    no_cursors_file = "Tests/images/cur/no_cursors.cur"
 
     cur = CurImagePlugin.CurImageFile(TEST_FILE)
     cur.fp.close()

From c11dea98b60809a004abed86dac1a2e83b85748b Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Thu, 22 Sep 2022 19:06:26 -0600
Subject: [PATCH 16/53] added .ani plugin

---
 Tests/images/ani/aero_busy.ani | Bin 0 -> 556304 bytes
 Tests/images/ani/posy_busy.ani | Bin 0 -> 5311066 bytes
 Tests/images/ani/stopwtch.ani  | Bin 0 -> 6712 bytes
 src/PIL/AniImagePlugin.py      | 378 +++++++++++++++++++++++++++++++++
 src/PIL/__init__.py            |   1 +
 5 files changed, 379 insertions(+)
 create mode 100644 Tests/images/ani/aero_busy.ani
 create mode 100644 Tests/images/ani/posy_busy.ani
 create mode 100644 Tests/images/ani/stopwtch.ani
 create mode 100644 src/PIL/AniImagePlugin.py

diff --git a/Tests/images/ani/aero_busy.ani b/Tests/images/ani/aero_busy.ani
new file mode 100644
index 0000000000000000000000000000000000000000..46e1e36d22c0780f970753198f4cfbf4621dfe2c
GIT binary patch
diff --git a/Tests/images/ani/posy_busy.ani b/Tests/images/ani/posy_busy.ani
new file mode 100644
index 0000000000000000000000000000000000000000..8ac0abef54566307a98c21a0899762273f667214
GIT binary patch
diff --git a/Tests/images/ani/stopwtch.ani b/Tests/images/ani/stopwtch.ani
new file mode 100644
index 0000000000000000000000000000000000000000..1b3461ef599fb929e2b88abf89828ade3632ea0d
GIT binary patch
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
new file mode 100644
index 00000000000..af3af6368bc
--- /dev/null
+++ b/src/PIL/AniImagePlugin.py
@@ -0,0 +1,378 @@
+from io import BytesIO
+import struct
+from PIL import Image, CurImagePlugin, ImageFile, BmpImagePlugin
+from PIL._binary import i16le as i16
+from PIL._binary import i32le as i32
+from PIL._binary import o8
+from PIL._binary import o16le as o16
+from PIL._binary import o32le as o32
+
+def _accept(s):
+    return s[:4] == b'RIFF'
+
+
+def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
+    fp.write(b"\0\0\2\0")
+    bmp = True
+    sizes = info.get(
+        "sizes",
+        [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
+    )
+    hotspots = info.get("hotspots", [(0, 0) for i in range(len(sizes))])
+    if len(hotspots) != len(sizes):
+        raise ValueError("Number of hotspots must be equal to number of cursor sizes")
+
+    frames = []
+    width, height = im.size
+    for size in sorted(set(sizes)):
+        if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
+            continue
+
+        # TODO: invent a more convenient method for proportional scalings
+        frame = im.copy()
+        frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
+        frames.append(frame)
+
+    fp.write(o16(len(frames)))  # idCount(2)
+    offset = fp.tell() + len(frames) * 16
+    for hotspot, frame in zip(hotspots, frames):
+        width, height = frame.size
+        # 0 means 256
+        fp.write(o8(width if width < 256 else 0))  # bWidth(1)
+        fp.write(o8(height if height < 256 else 0))  # bHeight(1)
+
+        bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
+        fp.write(o8(colors))  # bColorCount(1)
+        fp.write(b"\0")  # bReserved(1)
+        fp.write(o16(hotspot[0]))  # x_hotspot(2)
+        fp.write(o16(hotspot[1]))  # y_hotspot(2)
+
+        image_io = BytesIO()
+        if bmp:
+            frame.save(image_io, "dib")
+
+            if bits != 32:
+                and_mask = Image.new("1", size)
+                ImageFile._save(
+                    and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
+                )
+        else:
+            frame.save(image_io, "png")
+        image_io.seek(0)
+        image_bytes = image_io.read()
+        if bmp:
+            image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
+        bytes_len = len(image_bytes)
+        fp.write(o32(bytes_len))  # dwBytesInRes(4)
+        fp.write(o32(offset))  # dwImageOffset(4)
+        current = fp.tell()
+        fp.seek(offset)
+        fp.write(image_bytes)
+        offset = offset + bytes_len
+        fp.seek(current)
+
+def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
+    fp.write(b"anih")
+    anih = o32(36) + o32(36) + (o32(1) * 2) + (o32(0) * 4) + o32(60) + o32(1)
+    fp.write(anih)
+    
+    fp.write(b"LIST" + o32(0))
+    list_offset = fp.tell()
+    fp.write(b"fram")
+
+    fp.write(b"icon" + o32(0))
+    icon_offset = fp.tell()
+    with BytesIO(b"") as icon_fp:
+        _save_frame(im, icon_fp, filename, im.encoderinfo)
+        icon_fp.seek(0)
+        icon_data = icon_fp.read()
+
+    fp.write(icon_data)
+    fram_end = fp.tell()
+    
+    fp.seek(icon_offset - 4)
+    icon_size = fram_end - icon_offset
+    fp.write(o32(icon_size))
+
+    fp.seek(list_offset - 4)
+    list_size = fram_end - list_offset
+    fp.write(o32(list_size))
+
+    fp.seek(fram_end)
+
+def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):  
+    anih_offset = fp.tell()
+    fp.write(b"anih" + o32(36))
+    fp.write(o32(0) * 9)
+
+    fp.write(b"LIST" + o32(0))
+    list_offset = fp.tell()
+    fp.write(b"fram")
+
+    frames = [im] + im.encoderinfo.get("append_images", [])
+    for frame in frames:
+        fp.write(b"icon" + o32(0))
+        icon_offset = fp.tell()
+        with BytesIO(b"") as icon_fp:
+            _save_frame(frame, icon_fp, filename, im.encoderinfo)
+            icon_fp.seek(0)
+            icon_data = icon_fp.read()
+
+        fp.write(icon_data)
+        fram_end = fp.tell()
+    
+        fp.seek(icon_offset - 4)
+        icon_size = fram_end - icon_offset
+        fp.write(o32(icon_size))
+
+        fp.seek(fram_end)
+
+    fp.seek(list_offset - 4)
+    list_size = fram_end - list_offset
+    fp.write(o32(list_size))
+
+    fp.seek(fram_end)
+
+    seq = im.encoderinfo.get("seq", [])
+    if seq:
+        fp.write(b"seq " + o32(0))
+        seq_offset = fp.tell()
+        for i in seq:
+            if i >= len(frames):
+                raise ValueError("Sequence index out of animation frame bounds")
+            
+            fp.write(o32(i))
+        
+        fram_end = fp.tell()
+        fp.seek(seq_offset - 4)
+        seq_size = fram_end - seq_offset
+        fp.write(o32(seq_size))
+
+        fp.seek(fram_end)
+
+    rate = im.encoderinfo.get("rate", [])
+    if rate:
+        fp.write(b"rate" + o32(0))
+        rate_offset = fp.tell()
+        
+        if seq:
+            if len(rate) != len(seq):
+                raise ValueError("Length of rate must match rate of sequence")
+        else:
+            if len(rate) != len(frames):
+                raise ValueError("Length of rate must match number of frames")
+        
+        for r in rate:
+            fp.write(o32(r))
+
+        fram_end = fp.tell()
+        fp.seek(rate_offset - 4)
+        rate_size = fram_end - rate_offset
+        fp.write(o32(rate_size))
+
+        fp.seek(fram_end)
+        
+    bmp = im.encoderinfo.get("bitmap_format") == "bmp"
+    display_rate = im.encoderinfo.get("display_rate", 2)
+    n_frames = len(frames)
+    n_steps = len(seq) if seq else n_frames
+    flag = 1 if not seq else 3
+
+    fram_end = fp.tell()
+
+    fp.seek(anih_offset)
+    fp.write(b"anih")
+    anih = o32(36) + o32(36) + o32(n_frames) + o32(n_steps) + (o32(0) * 4) + o32(display_rate) + o32(flag)
+    fp.write(anih)
+
+    fp.seek(fram_end)
+
+
+
+def _write_info(im: Image.Image, fp: BytesIO, filename: str):
+    fp.write(b"LIST" + o32(0))
+    list_offset = fp.tell()
+
+    inam = im.encoderinfo.get("inam", filename)
+    iart = im.encoderinfo.get("iart", "Pillow")
+
+    if isinstance(inam, str):
+        inam = inam.encode()
+    if not isinstance(inam, bytes):
+        raise TypeError("'inam' argument must be either a string or bytes")
+
+    if isinstance(iart, str):
+        iart = iart.encode()
+    if not isinstance(iart, bytes):
+        raise TypeError("'iart' argument must be either a string or bytes")
+
+    fp.write(b"INFO")
+    fp.write(b"INAM" + o32(0))
+    inam_offset = fp.tell()
+
+    fp.write(inam + b"\x00")
+    inam_size = fp.tell() - inam_offset
+
+    fp.write(b"IART" + o32(0))
+    iart_offset = fp.tell()    
+
+    fp.write(iart + b"\x00")
+    iart_size = fp.tell() - iart_offset
+
+    info_end = fp.tell()
+
+    fp.seek(iart_offset - 4)
+    fp.write(o32(iart_size))
+
+    fp.seek(inam_offset - 4)
+    fp.write(o32(inam_size))
+
+    fp.seek(list_offset - 4)
+    list_size = info_end - list_offset
+    fp.write(o32(list_size))
+    
+    fp.seek(info_end)
+
+def _save(im: Image.Image, fp: BytesIO, filename: str):
+    fp.write(b"RIFF\x00\x00\x00\x00")
+    riff_offset = fp.tell()
+
+    fp.write(b"ACON")
+    _write_info(im, fp, filename)
+    
+    frames = im.encoderinfo.get("append_images", [])
+    if frames:
+        _write_multiple_frames(im, fp, filename)
+    else:
+        _write_single_frame(im, fp, filename)
+    pass
+
+    riff_end = fp.tell()
+    fp.seek(riff_offset - 4)
+    riff_size = riff_end - riff_offset
+    fp.write(o32(riff_size))
+    
+    fp.seek(riff_end)
+
+class AniFile:
+    def __init__(self, buf: BytesIO) -> None:
+        self.image_data = []
+
+        self.buf = buf
+        self.rate = None
+        self.seq = None
+        self.anih = None
+
+        riff, size, fformat = struct.unpack('<4sI4s', buf.read(12))
+        if riff != b'RIFF':
+            SyntaxError("Not an ANI file")
+
+        self.riff = {
+            "size": size,
+            "fformat": fformat
+        }
+
+        chunkOffset = buf.tell()
+        while chunkOffset < self.riff['size']:
+            buf.seek(chunkOffset)
+            chunk, size = struct.unpack('<4sI', buf.read(8))
+            chunkOffset = chunkOffset + size + 8
+
+            if chunk == b'anih':
+                s = buf.read(36)
+                self.anih = {
+                    "size":  i32(s),  # Data structure size (in bytes)
+                    "nFrames": i32(s, 4),  # Number of frames
+                    "nSteps": i32(s, 8),  # Number of frames before repeat
+                    "iWidth": i32(s, 12),  # Width of frame (in pixels)
+                    "iHeight": i32(s, 16),  # Height of frame (in pixels)
+                    "iBitCount": i32(s, 20),  # Number of bits per pixel
+                    "nPlanes": i32(s, 24),  # Number of color planes
+                    # Default frame display rate (1/60th sec)
+                    "iDispRate": i32(s, 28),
+                    "bfAttributes": i32(s, 32),  # ANI attribute bit flags
+                }
+
+            if chunk == b'seq ':
+                s = buf.read(size)
+                self.seq = [i32(s, i*4) for i in range(size // 4)]
+
+            if chunk == b'rate':
+                s = buf.read(size)
+                self.rate = [i32(s, i*4) for i in range(size // 4)]
+
+            if chunk == b'LIST':
+                listtype = struct.unpack('<4s', buf.read(4))[0]
+                if listtype != b'fram':
+                    continue
+
+                listOffset = 0
+                while listOffset < size - 8:
+                    _, lSize = struct.unpack('<4sI', buf.read(8))
+                    self.image_data.append({"offset": buf.tell(), "size": lSize})
+
+                    buf.read(lSize)
+                    listOffset = listOffset + lSize + 8
+
+        if self.anih is None:
+            raise SyntaxError("not an ANI file")
+
+        if self.seq is None:
+            self.seq = [i for i in range(self.anih["nFrames"])]
+
+        if self.rate is None:
+            self.rate = [self.anih["iDispRate"] for i in range(self.anih["nFrames"])]
+
+    def frame(self, frame):
+        if frame > self.anih["nFrames"]:
+            raise ValueError("Frame index out of animation bounds")
+
+        offset, size = self.image_data[frame].values()
+        self.buf.seek(offset)
+        data = self.buf.read(size)
+
+        im = CurImagePlugin.CurImageFile(BytesIO(data))
+        return im
+        
+
+
+class AniImageFile(ImageFile.ImageFile):
+    format = "ANI"
+    format_description = "Windows Animated Cursor"
+    
+    def _open(self):       
+        self.ani = AniFile(self.fp)
+        self.frame = 0
+        self.seek(0)
+        self.size = self.im.size
+        self.info["seq"] = self.ani.seq
+        self.info["rate"] = self.ani.rate
+
+    @property
+    def size(self):
+        return self._size
+
+    @size.setter
+    def size(self, value):
+        if value not in self.info['sizes']:
+            raise ValueError("This is not one of the allowed sizes of this image")
+        self._size = value
+
+
+    def load(self):
+        im = self.ani.frame(self.frame)  
+        self.info['sizes'] = im.info['sizes']
+        self.im = im.im
+        self.mode = im.mode
+
+    def seek(self, frame):
+        if frame > self.ani.anih["nFrames"]:
+            raise ValueError("Frame index out of animation bounds")
+
+        self.frame = frame
+        self.load()
+
+        
+Image.register_open(AniImageFile.format, AniImageFile, _accept)
+Image.register_extension(AniImageFile.format, ".ani")
+Image.register_save(AniImageFile.format, _save)
\ No newline at end of file
diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py
index e65b155b2dc..ab48f74e8f1 100644
--- a/src/PIL/__init__.py
+++ b/src/PIL/__init__.py
@@ -23,6 +23,7 @@
 
 
 _plugins = [
+    "AniImagePlugin",
     "BlpImagePlugin",
     "BmpImagePlugin",
     "BufrStubImagePlugin",

From 86476a9bd2d1ef82431d7aa7a870166aa8d85ab3 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Fri, 23 Sep 2022 01:08:03 +0000
Subject: [PATCH 17/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 Tests/test_file_cur.py    |  2 +-
 src/PIL/AniImagePlugin.py | 98 +++++++++++++++++++++------------------
 src/PIL/CurImagePlugin.py | 12 +++--
 3 files changed, 63 insertions(+), 49 deletions(-)

diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index 9ab1b4eeb30..a304245a240 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -18,7 +18,7 @@ def test_posy_link():
         assert im.size == (128, 128)
         assert im.getpixel((0, 0)) == (0, 0, 0, 0)
         assert im.getpixel((20, 20)) == (0, 0, 0, 255)
-        assert im.getpixel((40,40)) == (255, 255, 255, 255)
+        assert im.getpixel((40, 40)) == (255, 255, 255, 255)
 
 
 def test_invalid_file():
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index af3af6368bc..8cfa612a90f 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -1,14 +1,16 @@
-from io import BytesIO
 import struct
-from PIL import Image, CurImagePlugin, ImageFile, BmpImagePlugin
+from io import BytesIO
+
+from PIL import BmpImagePlugin, CurImagePlugin, Image, ImageFile
 from PIL._binary import i16le as i16
 from PIL._binary import i32le as i32
 from PIL._binary import o8
 from PIL._binary import o16le as o16
 from PIL._binary import o32le as o32
 
+
 def _accept(s):
-    return s[:4] == b'RIFF'
+    return s[:4] == b"RIFF"
 
 
 def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
@@ -71,11 +73,12 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
         offset = offset + bytes_len
         fp.seek(current)
 
+
 def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
     fp.write(b"anih")
     anih = o32(36) + o32(36) + (o32(1) * 2) + (o32(0) * 4) + o32(60) + o32(1)
     fp.write(anih)
-    
+
     fp.write(b"LIST" + o32(0))
     list_offset = fp.tell()
     fp.write(b"fram")
@@ -89,7 +92,7 @@ def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
 
     fp.write(icon_data)
     fram_end = fp.tell()
-    
+
     fp.seek(icon_offset - 4)
     icon_size = fram_end - icon_offset
     fp.write(o32(icon_size))
@@ -100,7 +103,8 @@ def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
 
     fp.seek(fram_end)
 
-def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):  
+
+def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
     anih_offset = fp.tell()
     fp.write(b"anih" + o32(36))
     fp.write(o32(0) * 9)
@@ -120,7 +124,7 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):
 
         fp.write(icon_data)
         fram_end = fp.tell()
-    
+
         fp.seek(icon_offset - 4)
         icon_size = fram_end - icon_offset
         fp.write(o32(icon_size))
@@ -140,9 +144,9 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):
         for i in seq:
             if i >= len(frames):
                 raise ValueError("Sequence index out of animation frame bounds")
-            
+
             fp.write(o32(i))
-        
+
         fram_end = fp.tell()
         fp.seek(seq_offset - 4)
         seq_size = fram_end - seq_offset
@@ -154,14 +158,14 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):
     if rate:
         fp.write(b"rate" + o32(0))
         rate_offset = fp.tell()
-        
+
         if seq:
             if len(rate) != len(seq):
                 raise ValueError("Length of rate must match rate of sequence")
         else:
             if len(rate) != len(frames):
                 raise ValueError("Length of rate must match number of frames")
-        
+
         for r in rate:
             fp.write(o32(r))
 
@@ -171,7 +175,7 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):
         fp.write(o32(rate_size))
 
         fp.seek(fram_end)
-        
+
     bmp = im.encoderinfo.get("bitmap_format") == "bmp"
     display_rate = im.encoderinfo.get("display_rate", 2)
     n_frames = len(frames)
@@ -182,13 +186,20 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename:str):
 
     fp.seek(anih_offset)
     fp.write(b"anih")
-    anih = o32(36) + o32(36) + o32(n_frames) + o32(n_steps) + (o32(0) * 4) + o32(display_rate) + o32(flag)
+    anih = (
+        o32(36)
+        + o32(36)
+        + o32(n_frames)
+        + o32(n_steps)
+        + (o32(0) * 4)
+        + o32(display_rate)
+        + o32(flag)
+    )
     fp.write(anih)
 
     fp.seek(fram_end)
 
 
-
 def _write_info(im: Image.Image, fp: BytesIO, filename: str):
     fp.write(b"LIST" + o32(0))
     list_offset = fp.tell()
@@ -214,7 +225,7 @@ def _write_info(im: Image.Image, fp: BytesIO, filename: str):
     inam_size = fp.tell() - inam_offset
 
     fp.write(b"IART" + o32(0))
-    iart_offset = fp.tell()    
+    iart_offset = fp.tell()
 
     fp.write(iart + b"\x00")
     iart_size = fp.tell() - iart_offset
@@ -230,16 +241,17 @@ def _write_info(im: Image.Image, fp: BytesIO, filename: str):
     fp.seek(list_offset - 4)
     list_size = info_end - list_offset
     fp.write(o32(list_size))
-    
+
     fp.seek(info_end)
 
+
 def _save(im: Image.Image, fp: BytesIO, filename: str):
     fp.write(b"RIFF\x00\x00\x00\x00")
     riff_offset = fp.tell()
 
     fp.write(b"ACON")
     _write_info(im, fp, filename)
-    
+
     frames = im.encoderinfo.get("append_images", [])
     if frames:
         _write_multiple_frames(im, fp, filename)
@@ -251,9 +263,10 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
     fp.seek(riff_offset - 4)
     riff_size = riff_end - riff_offset
     fp.write(o32(riff_size))
-    
+
     fp.seek(riff_end)
 
+
 class AniFile:
     def __init__(self, buf: BytesIO) -> None:
         self.image_data = []
@@ -263,25 +276,22 @@ def __init__(self, buf: BytesIO) -> None:
         self.seq = None
         self.anih = None
 
-        riff, size, fformat = struct.unpack('<4sI4s', buf.read(12))
-        if riff != b'RIFF':
+        riff, size, fformat = struct.unpack("<4sI4s", buf.read(12))
+        if riff != b"RIFF":
             SyntaxError("Not an ANI file")
 
-        self.riff = {
-            "size": size,
-            "fformat": fformat
-        }
+        self.riff = {"size": size, "fformat": fformat}
 
         chunkOffset = buf.tell()
-        while chunkOffset < self.riff['size']:
+        while chunkOffset < self.riff["size"]:
             buf.seek(chunkOffset)
-            chunk, size = struct.unpack('<4sI', buf.read(8))
+            chunk, size = struct.unpack("<4sI", buf.read(8))
             chunkOffset = chunkOffset + size + 8
 
-            if chunk == b'anih':
+            if chunk == b"anih":
                 s = buf.read(36)
                 self.anih = {
-                    "size":  i32(s),  # Data structure size (in bytes)
+                    "size": i32(s),  # Data structure size (in bytes)
                     "nFrames": i32(s, 4),  # Number of frames
                     "nSteps": i32(s, 8),  # Number of frames before repeat
                     "iWidth": i32(s, 12),  # Width of frame (in pixels)
@@ -293,22 +303,22 @@ def __init__(self, buf: BytesIO) -> None:
                     "bfAttributes": i32(s, 32),  # ANI attribute bit flags
                 }
 
-            if chunk == b'seq ':
+            if chunk == b"seq ":
                 s = buf.read(size)
-                self.seq = [i32(s, i*4) for i in range(size // 4)]
+                self.seq = [i32(s, i * 4) for i in range(size // 4)]
 
-            if chunk == b'rate':
+            if chunk == b"rate":
                 s = buf.read(size)
-                self.rate = [i32(s, i*4) for i in range(size // 4)]
+                self.rate = [i32(s, i * 4) for i in range(size // 4)]
 
-            if chunk == b'LIST':
-                listtype = struct.unpack('<4s', buf.read(4))[0]
-                if listtype != b'fram':
+            if chunk == b"LIST":
+                listtype = struct.unpack("<4s", buf.read(4))[0]
+                if listtype != b"fram":
                     continue
 
                 listOffset = 0
                 while listOffset < size - 8:
-                    _, lSize = struct.unpack('<4sI', buf.read(8))
+                    _, lSize = struct.unpack("<4sI", buf.read(8))
                     self.image_data.append({"offset": buf.tell(), "size": lSize})
 
                     buf.read(lSize)
@@ -333,14 +343,13 @@ def frame(self, frame):
 
         im = CurImagePlugin.CurImageFile(BytesIO(data))
         return im
-        
 
 
 class AniImageFile(ImageFile.ImageFile):
     format = "ANI"
     format_description = "Windows Animated Cursor"
-    
-    def _open(self):       
+
+    def _open(self):
         self.ani = AniFile(self.fp)
         self.frame = 0
         self.seek(0)
@@ -354,14 +363,13 @@ def size(self):
 
     @size.setter
     def size(self, value):
-        if value not in self.info['sizes']:
+        if value not in self.info["sizes"]:
             raise ValueError("This is not one of the allowed sizes of this image")
         self._size = value
 
-
     def load(self):
-        im = self.ani.frame(self.frame)  
-        self.info['sizes'] = im.info['sizes']
+        im = self.ani.frame(self.frame)
+        self.info["sizes"] = im.info["sizes"]
         self.im = im.im
         self.mode = im.mode
 
@@ -372,7 +380,7 @@ def seek(self, frame):
         self.frame = frame
         self.load()
 
-        
+
 Image.register_open(AniImageFile.format, AniImageFile, _accept)
 Image.register_extension(AniImageFile.format, ".ani")
-Image.register_save(AniImageFile.format, _save)
\ No newline at end of file
+Image.register_save(AniImageFile.format, _save)
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index a41530f7f97..4a24c1032a3 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -29,6 +29,7 @@
 
 _MAGIC = b"\0\0\2\0"
 
+
 def _save(im: Image.Image, fp: BytesIO, filename: str):
     fp.write(_MAGIC)
     bmp = im.encoderinfo.get("bitmap_format", "") == "bmp"
@@ -138,13 +139,18 @@ def __init__(self, buf: BytesIO):
 
             # TODO: This needs further investigation. Cursor files do not really specify their bpp
             # like ICO's as those bits are used for the y_hotspot. For now, bpp is calculated by
-            # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number 
+            # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number
             # of pixels. This seems to work well so far.
             BITMAP_INFO_HEADER_SIZE = 40
-            bpp_without_and = ((icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8) // icon_header["square"]
+            bpp_without_and = (
+                (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8
+            ) // icon_header["square"]
 
             if bpp_without_and != 32:
-                icon_header["bpp"] = ((icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8 - icon_header["square"]) // icon_header["square"]
+                icon_header["bpp"] = (
+                    (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8
+                    - icon_header["square"]
+                ) // icon_header["square"]
             else:
                 icon_header["bpp"] = bpp_without_and
 

From 17453f207de15d866936b7eded94142647c0386e Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Thu, 22 Sep 2022 19:22:25 -0600
Subject: [PATCH 18/53] fixed bug in test

---
 Tests/test_file_cur.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index a304245a240..9675312e9ae 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -29,7 +29,7 @@ def test_invalid_file():
 
     no_cursors_file = "Tests/images/cur/no_cursors.cur"
 
-    cur = CurImagePlugin.CurImageFile(TEST_FILE)
+    cur = CurImagePlugin.CurImageFile("Tests/images/cur/deerstalker.cur")
     cur.fp.close()
     with open(no_cursors_file, "rb") as cur.fp:
         with pytest.raises(TypeError):

From cd4ee0473cd19d2a5a47cc52565dfa72922480b7 Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 07:53:28 -0600
Subject: [PATCH 19/53] docstring for cur plugin

---
 src/PIL/CurImagePlugin.py | 36 +++++++++++++++++++++++++++++++++---
 1 file changed, 33 insertions(+), 3 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 4a24c1032a3..6c8ac58f00f 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -33,17 +33,26 @@
 def _save(im: Image.Image, fp: BytesIO, filename: str):
     fp.write(_MAGIC)
     bmp = im.encoderinfo.get("bitmap_format", "") == "bmp"
-    sizes = im.encoderinfo.get(
+    s = im.encoderinfo.get(
         "sizes",
         [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
     )
-    hotspots = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(sizes))])
+    h = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(s))])
+
     if len(hotspots) != len(sizes):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
+    # sort and remove duplicate sizes
+    sizes = []
+    hotspots = []
+    for size, hotspot in zip(*sorted(zip(s, h), lambda x: x[0])):
+        if size not in sizes:
+            sizes.append(size)
+            hotspots.append(hotspots)
+
     frames = []
     width, height = im.size
-    for size in sorted(set(sizes)):
+    for size in sizes:
         if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
             continue
 
@@ -167,6 +176,27 @@ def hotspots(self):
 
 
 class CurImageFile(IcoImagePlugin.IcoImageFile):
+    """
+    PIL read-only image support for Microsoft Windows .cur files.
+
+    By default the largest resolution image in the file will be loaded. This
+    can be changed by altering the 'size' attribute before calling 'load'.
+
+    The info dictionary has a key 'sizes' that is a list of the sizes available
+    in the icon file. It also contains key 'hotspots' that is a list of the
+    cursor hotspots.
+
+    Handles classic, XP and Vista icon formats.
+
+    When saving, PNG compression is used. Support for this was only added in
+    Windows Vista. If you are unable to view the icon in Windows, convert the
+    image to "RGBA" mode before saving. This is an extension of the IcoImagePlugin.
+
+    Raises:
+        ValueError: The number of sizes and hotspots do not match.
+        SyntaxError: The file is not a cursor file.
+        TypeError: There are no cursors contained withing the file.
+    """
 
     format = "CUR"
     format_description = "Windows Cursor"

From da093b9fefc2fde15bbeb2a07f37f800732d84ab Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 07:53:35 -0600
Subject: [PATCH 20/53] doc string for ani plugin

---
 src/PIL/AniImagePlugin.py | 53 ++++++++++++++++++++++++++++++++++-----
 1 file changed, 47 insertions(+), 6 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 8cfa612a90f..9a30cfdc6d0 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -16,14 +16,23 @@ def _accept(s):
 def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
     fp.write(b"\0\0\2\0")
     bmp = True
-    sizes = info.get(
+    s = im.encoderinfo.get(
         "sizes",
         [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
     )
-    hotspots = info.get("hotspots", [(0, 0) for i in range(len(sizes))])
+    h = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(s))])
+
     if len(hotspots) != len(sizes):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
+    # sort and remove duplicate sizes
+    sizes = []
+    hotspots = []
+    for size, hotspot in zip(*sorted(zip(s, h), lambda x: x[0])):
+        if size not in sizes:
+            sizes.append(size)
+            hotspots.append(hotspots)
+
     frames = []
     width, height = im.size
     for size in sorted(set(sizes)):
@@ -161,7 +170,7 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
 
         if seq:
             if len(rate) != len(seq):
-                raise ValueError("Length of rate must match rate of sequence")
+                raise ValueError("Length of rate must match length of sequence")
         else:
             if len(rate) != len(frames):
                 raise ValueError("Length of rate must match number of frames")
@@ -344,18 +353,49 @@ def frame(self, frame):
         im = CurImagePlugin.CurImageFile(BytesIO(data))
         return im
 
+    def sizes(self):
+        return [data['size'] for data in self.image_data]
+
+    def hotspots(self):
+        pass
+
 
 class AniImageFile(ImageFile.ImageFile):
+    """
+    PIL read-only image support for Microsoft Windows .ani files.
+
+    By default the largest resolution image and first frame in the file will 
+    be loaded.
+
+    The info dictionary has four keys:
+        'seq': the sequence of the frames used for animation.
+        'rate': the rate (in 1/60th of a second) for each frame in the sequence.
+        'frames': the number of frames in the file.
+        'sizes': a list of the sizes available for the current frame.
+        'hotspots': a list of the cursor hotspots for a given frame.
+
+    Saving is similar to GIF. Arguments for encoding are:
+        'sizes': The sizes of the cursor (used for scaling by windows).
+        'hotspots': The hotspot for each size, with (0, 0) being the top left.
+        'append_images': The frames for animation. Please note that the sizes and
+        hotspots are shared across each frame.
+        'seq': The sequence of frames, zero indexed.
+        'rate': The rate for each frame in the seq. Must be the same length as seq or
+        equal to the number of frames if seq is not passed.
+    """
+
     format = "ANI"
     format_description = "Windows Animated Cursor"
 
     def _open(self):
         self.ani = AniFile(self.fp)
+        self.info["seq"] = self.ani.seq
+        self.info["rate"] = self.ani.rate
+        self.info["frames"] = self.ani.anih["nFrames"]
+
         self.frame = 0
         self.seek(0)
         self.size = self.im.size
-        self.info["seq"] = self.ani.seq
-        self.info["rate"] = self.ani.rate
 
     @property
     def size(self):
@@ -370,11 +410,12 @@ def size(self, value):
     def load(self):
         im = self.ani.frame(self.frame)
         self.info["sizes"] = im.info["sizes"]
+        self.info["hotspots"] = im.info["hotspots"]
         self.im = im.im
         self.mode = im.mode
 
     def seek(self, frame):
-        if frame > self.ani.anih["nFrames"]:
+        if frame > self.info["frames"]:
             raise ValueError("Frame index out of animation bounds")
 
         self.frame = frame

From fc8a288d90dd5ff328ab16249b72533c12438aa9 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 27 Sep 2022 13:57:19 +0000
Subject: [PATCH 21/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/AniImagePlugin.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 9a30cfdc6d0..1e392707a43 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -354,7 +354,7 @@ def frame(self, frame):
         return im
 
     def sizes(self):
-        return [data['size'] for data in self.image_data]
+        return [data["size"] for data in self.image_data]
 
     def hotspots(self):
         pass
@@ -364,7 +364,7 @@ class AniImageFile(ImageFile.ImageFile):
     """
     PIL read-only image support for Microsoft Windows .ani files.
 
-    By default the largest resolution image and first frame in the file will 
+    By default the largest resolution image and first frame in the file will
     be loaded.
 
     The info dictionary has four keys:

From 084704d156f25a5ac59518fd3f5432bdd56fc29b Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 07:59:41 -0600
Subject: [PATCH 22/53] linting

---
 src/PIL/AniImagePlugin.py |  4 +---
 src/PIL/CurImagePlugin.py | 11 ++++++-----
 2 files changed, 7 insertions(+), 8 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 9a30cfdc6d0..0b6cebf57a1 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -2,7 +2,6 @@
 from io import BytesIO
 
 from PIL import BmpImagePlugin, CurImagePlugin, Image, ImageFile
-from PIL._binary import i16le as i16
 from PIL._binary import i32le as i32
 from PIL._binary import o8
 from PIL._binary import o16le as o16
@@ -22,7 +21,7 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
     )
     h = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(s))])
 
-    if len(hotspots) != len(sizes):
+    if len(h) != len(s):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
     # sort and remove duplicate sizes
@@ -185,7 +184,6 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
 
         fp.seek(fram_end)
 
-    bmp = im.encoderinfo.get("bitmap_format") == "bmp"
     display_rate = im.encoderinfo.get("display_rate", 2)
     n_frames = len(frames)
     n_steps = len(seq) if seq else n_frames
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 6c8ac58f00f..2419b8cbf41 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -39,7 +39,7 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
     )
     h = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(s))])
 
-    if len(hotspots) != len(sizes):
+    if len(h) != len(s):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
     # sort and remove duplicate sizes
@@ -146,10 +146,11 @@ def __init__(self, buf: BytesIO):
             icon_header["dim"] = (icon_header["width"], icon_header["height"])
             icon_header["square"] = icon_header["width"] * icon_header["height"]
 
-            # TODO: This needs further investigation. Cursor files do not really specify their bpp
-            # like ICO's as those bits are used for the y_hotspot. For now, bpp is calculated by
-            # subtracting the AND mask (equal to number of pixels * 1bpp) and dividing by the number
-            # of pixels. This seems to work well so far.
+            # TODO: This needs further investigation. Cursor files do not really 
+            # specify their bpp like ICO's as those bits are used for the y_hotspot. 
+            # For now, bpp is calculated by subtracting the AND mask (equal to number 
+            # of pixels * 1bpp) and dividing by the number of pixels. This seems 
+            # to work well so far.
             BITMAP_INFO_HEADER_SIZE = 40
             bpp_without_and = (
                 (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8

From bac4ae4c42539eb37aed2a8519a46fba367069a3 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 27 Sep 2022 14:00:26 +0000
Subject: [PATCH 23/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/CurImagePlugin.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 2419b8cbf41..58185f4314a 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -146,10 +146,10 @@ def __init__(self, buf: BytesIO):
             icon_header["dim"] = (icon_header["width"], icon_header["height"])
             icon_header["square"] = icon_header["width"] * icon_header["height"]
 
-            # TODO: This needs further investigation. Cursor files do not really 
-            # specify their bpp like ICO's as those bits are used for the y_hotspot. 
-            # For now, bpp is calculated by subtracting the AND mask (equal to number 
-            # of pixels * 1bpp) and dividing by the number of pixels. This seems 
+            # TODO: This needs further investigation. Cursor files do not really
+            # specify their bpp like ICO's as those bits are used for the y_hotspot.
+            # For now, bpp is calculated by subtracting the AND mask (equal to number
+            # of pixels * 1bpp) and dividing by the number of pixels. This seems
             # to work well so far.
             BITMAP_INFO_HEADER_SIZE = 40
             bpp_without_and = (

From 01d71be3fa35d002e259f0b8438d37543d0fc91a Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 09:08:56 -0600
Subject: [PATCH 24/53] bitmaps should be transparent if 32bits

---
 src/PIL/BmpImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index 1041ab763d2..bc81f3274c1 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -209,7 +209,7 @@ def _bitmap(self, header=0, offset=0):
             else:
                 raise OSError("Unsupported BMP bitfields layout")
         elif file_info["compression"] == self.RAW:
-            if file_info["bits"] == 32 and header == 22:  # 32-bit .cur offset
+            if file_info["bits"] == 32:  # 32-bit has transparency
                 raw_mode, self.mode = "BGRA", "RGBA"
         elif file_info["compression"] == self.RLE8:
             decoder_name = "bmp_rle"

From 551e2a6afccd9828d63d12ce2732a868614f9b6a Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 12:16:32 -0600
Subject: [PATCH 25/53] completed cursor tests

---
 Tests/images/cur/win98_arrow.png | Bin 0 -> 199 bytes
 Tests/test_file_cur.py           |  82 +++++++++++++++++++++++++++++++
 src/PIL/CurImagePlugin.py        |  18 +++----
 3 files changed, 91 insertions(+), 9 deletions(-)
 create mode 100644 Tests/images/cur/win98_arrow.png

diff --git a/Tests/images/cur/win98_arrow.png b/Tests/images/cur/win98_arrow.png
new file mode 100644
index 0000000000000000000000000000000000000000..f8bf4b1a76a0d48e585c267b4cf6f47e03d203ae
GIT binary patch
diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index 9675312e9ae..07eae8cadd9 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -1,11 +1,13 @@
 import pytest
 
+from io import BytesIO
 from PIL import CurImagePlugin, Image
 
 
 def test_deerstalker():
     with Image.open("Tests/images/cur/deerstalker.cur") as im:
         assert im.size == (32, 32)
+        assert im.info['hotspots'] == [(0, 0)]
         assert isinstance(im, CurImagePlugin.CurImageFile)
         # Check some pixel colors to ensure image is loaded properly
         assert im.getpixel((10, 1)) == (0, 0, 0, 0)
@@ -16,10 +18,37 @@ def test_deerstalker():
 def test_posy_link():
     with Image.open("Tests/images/cur/posy_link.cur") as im:
         assert im.size == (128, 128)
+        assert im.info['sizes'] == [(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)]
+        assert im.info['hotspots'] == [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
+        # check some pixel colors
         assert im.getpixel((0, 0)) == (0, 0, 0, 0)
         assert im.getpixel((20, 20)) == (0, 0, 0, 255)
         assert im.getpixel((40, 40)) == (255, 255, 255, 255)
 
+        im.size = (32, 32)
+        im.load()
+        assert im.getpixel((0, 0)) == (0, 0, 0, 0)
+        assert im.getpixel((10, 10)) == (191, 191, 191, 255)
+
+
+def test_stopwtch():
+    with Image.open("Tests/images/cur/stopwtch.cur") as im:
+        assert im.size == (32, 32)
+        assert im.info["hotspots"] == [(16, 19)]
+
+        assert im.getpixel((16, 16)) == (0, 0, 255, 255)
+        assert im.getpixel((8, 16)) == (255, 0, 0, 255)
+
+
+def test_win98_arrow():
+    with Image.open("Tests/images/cur/win98_arrow.cur") as im:
+        assert im.size == (32, 32)
+        assert im.info["hotspots"] == [(10, 10)]
+
+        assert im.getpixel((0, 0)) == (0, 0, 0, 0)
+        assert im.getpixel((16, 16)) == (0, 0, 0, 255)
+        assert im.getpixel((14, 19)) == (255, 255, 255, 255)
+
 
 def test_invalid_file():
     invalid_file = "Tests/images/cur/posy_link.png"
@@ -34,3 +63,56 @@ def test_invalid_file():
     with open(no_cursors_file, "rb") as cur.fp:
         with pytest.raises(TypeError):
             cur._open()
+
+
+def test_save_win98_arrow():
+    with Image.open("Tests/images/cur/win98_arrow.png") as im:
+        # save the data
+        with BytesIO() as output:
+            im.save(output, format="CUR", sizes=[(32, 32)], hotspots=[
+                    (10, 10)], bitmap_format="bmp")
+
+            with Image.open(output) as im2:
+                assert im.tobytes() == im2.tobytes()
+
+        with BytesIO() as output:
+            im.save(output, format="CUR")
+
+            # check default save params
+            with Image.open(output) as im2:
+                assert im2.size == (32, 32)
+                assert im2.info["sizes"] == [(32, 32), (24, 24), (16, 16)]
+                assert im2.info["hotspots"] == [(0, 0), (0, 0), (0, 0)]
+
+
+def test_save_posy_link():
+    sizes = [(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)]
+    hotspots = [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
+
+    with Image.open("Tests/images/cur/posy_link.png") as im:
+        # save the data
+        with BytesIO() as output:
+            im.save(output, sizes=sizes, hotspots=hotspots,
+                    format="CUR", bitmap_format="bmp")
+
+            # make sure saved output is readable
+            # and sizes/hotspots are correct
+            with Image.open(output, formats=["CUR"]) as im2:
+                assert (128, 128) == im2.size
+                assert sizes == im2.info['sizes']
+
+        with BytesIO() as output:
+            im.save(output, sizes=sizes[3:], hotspots=hotspots[3:],
+                    format="CUR")
+
+            # make sure saved output is readable
+            # and sizes/hotspots are correct
+            with Image.open(output, formats=["CUR"]) as im2:
+                assert (48, 48) == im2.size
+                assert sizes[3:] == im2.info['sizes']
+
+            # make sure error is thrown when size and hotspot len's
+            # don't match
+            with pytest.raises(ValueError):
+                im.save(output, sizes=sizes[2:], hotspots=hotspots[3:],
+                        format="CUR", bitmap_format="bmp")
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 2419b8cbf41..9e60ecc1fbf 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -27,7 +27,7 @@
 #
 # --------------------------------------------------------------------
 
-_MAGIC = b"\0\0\2\0"
+_MAGIC = b"\x00\x00\x02\x00"
 
 
 def _save(im: Image.Image, fp: BytesIO, filename: str):
@@ -43,12 +43,11 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
     # sort and remove duplicate sizes
-    sizes = []
-    hotspots = []
-    for size, hotspot in zip(*sorted(zip(s, h), lambda x: x[0])):
+    sizes, hotspots = [], []
+    for size, hotspot in sorted(zip(s, h), key=lambda x: x[0]):
         if size not in sizes:
             sizes.append(size)
-            hotspots.append(hotspots)
+            hotspots.append(hotspot)
 
     frames = []
     width, height = im.size
@@ -90,6 +89,7 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
         image_bytes = image_io.read()
         if bmp:
             image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
+
         bytes_len = len(image_bytes)
         fp.write(o32(bytes_len))  # dwBytesInRes(4)
         fp.write(o32(offset))  # dwImageOffset(4)
@@ -146,10 +146,10 @@ def __init__(self, buf: BytesIO):
             icon_header["dim"] = (icon_header["width"], icon_header["height"])
             icon_header["square"] = icon_header["width"] * icon_header["height"]
 
-            # TODO: This needs further investigation. Cursor files do not really 
-            # specify their bpp like ICO's as those bits are used for the y_hotspot. 
-            # For now, bpp is calculated by subtracting the AND mask (equal to number 
-            # of pixels * 1bpp) and dividing by the number of pixels. This seems 
+            # TODO: This needs further investigation. Cursor files do not really
+            # specify their bpp like ICO's as those bits are used for the y_hotspot.
+            # For now, bpp is calculated by subtracting the AND mask (equal to number
+            # of pixels * 1bpp) and dividing by the number of pixels. This seems
             # to work well so far.
             BITMAP_INFO_HEADER_SIZE = 40
             bpp_without_and = (

From 5eb3e53502f5f45b2d7a349c53949ca8ddd0393f Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 12:16:52 -0600
Subject: [PATCH 26/53] added animated cursor tests

---
 Tests/images/ani/aero_busy_0.png  | Bin 0 -> 1536 bytes
 Tests/images/ani/aero_busy_8.png  | Bin 0 -> 1559 bytes
 Tests/images/ani/posy_busy_0.png  | Bin 0 -> 3145 bytes
 Tests/images/ani/posy_busy_24.png | Bin 0 -> 3192 bytes
 Tests/images/ani/stopwtch_0.png   | Bin 0 -> 344 bytes
 Tests/images/ani/stopwtch_5.png   | Bin 0 -> 331 bytes
 Tests/test_file_ani.py            | 132 ++++++++++++++++++++++++++++++
 src/PIL/AniImagePlugin.py         |  23 +++---
 8 files changed, 145 insertions(+), 10 deletions(-)
 create mode 100644 Tests/images/ani/aero_busy_0.png
 create mode 100644 Tests/images/ani/aero_busy_8.png
 create mode 100644 Tests/images/ani/posy_busy_0.png
 create mode 100644 Tests/images/ani/posy_busy_24.png
 create mode 100644 Tests/images/ani/stopwtch_0.png
 create mode 100644 Tests/images/ani/stopwtch_5.png
 create mode 100644 Tests/test_file_ani.py

diff --git a/Tests/images/ani/aero_busy_0.png b/Tests/images/ani/aero_busy_0.png
new file mode 100644
index 0000000000000000000000000000000000000000..d084aec9d404b4c7c21e5b9207d2fd191b52fe83
GIT binary patch
diff --git a/Tests/images/ani/aero_busy_8.png b/Tests/images/ani/aero_busy_8.png
new file mode 100644
index 0000000000000000000000000000000000000000..9d6e56ac1b072eb91b8bd450744c21acf28c5c4c
GIT binary patch
diff --git a/Tests/images/ani/posy_busy_0.png b/Tests/images/ani/posy_busy_0.png
new file mode 100644
index 0000000000000000000000000000000000000000..6dad8480512e3be15bf57245b0365576ce099f8e
GIT binary patch
diff --git a/Tests/images/ani/posy_busy_24.png b/Tests/images/ani/posy_busy_24.png
new file mode 100644
index 0000000000000000000000000000000000000000..684482835db06d244c82ef06f0704ef88d030577
GIT binary patch
diff --git a/Tests/images/ani/stopwtch_0.png b/Tests/images/ani/stopwtch_0.png
new file mode 100644
index 0000000000000000000000000000000000000000..3c6471229bba0fa76fa8c6bddb60e89867e60869
GIT binary patch
diff --git a/Tests/images/ani/stopwtch_5.png b/Tests/images/ani/stopwtch_5.png
new file mode 100644
index 0000000000000000000000000000000000000000..623d9283e0023fccebc19eaa0343a1620a1e3c59
GIT binary patch
diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
new file mode 100644
index 00000000000..cd7703164db
--- /dev/null
+++ b/Tests/test_file_ani.py
@@ -0,0 +1,132 @@
+from io import BytesIO
+import pytest
+from PIL import Image
+
+
+def test_aero_busy():
+    with Image.open("Tests/images/ani/aero_busy.ani") as im:
+        assert im.size == (64, 64)
+        assert im.info["frames"] == 18
+
+        with Image.open("Tests/images/ani/aero_busy_0.png") as png:
+            assert png.tobytes() == im.tobytes()
+
+        im.seek(8)
+        with Image.open("Tests/images/ani/aero_busy_8.png") as png:
+            assert png.tobytes() == im.tobytes()
+
+        with pytest.raises(EOFError):
+            im.seek(-1)
+
+        with pytest.raises(EOFError):
+            im.seek(18)
+
+
+def test_posy_busy():
+    with Image.open("Tests/images/ani/posy_busy.ani") as im:
+        assert im.size == (96, 96)
+        assert im.info["frames"] == 77
+
+        with Image.open("Tests/images/ani/posy_busy_0.png") as png:
+            assert png.tobytes() == im.tobytes()
+
+        im.seek(24)
+        with Image.open("Tests/images/ani/posy_busy_24.png") as png:
+            assert png.tobytes() == im.tobytes()
+
+        with pytest.raises(EOFError):
+            im.seek(77)
+
+
+def test_stopwtch():
+    with Image.open("Tests/images/ani/stopwtch.ani") as im:
+        assert im.size == (32, 32)
+        assert im.info["frames"] == 8
+
+        assert im.info["seq"][0] == 0
+        assert im.info["seq"][2] == 0
+
+        for i, r in enumerate(im.info["rate"]):
+            if i == 1 or i == 2:
+                assert r == 16
+            else:
+                assert r == 8
+
+        with Image.open("Tests/images/ani/stopwtch_0.png") as png:
+            assert png.tobytes() == im.tobytes()
+
+        im.seek(5)
+        with Image.open("Tests/images/ani/stopwtch_5.png") as png:
+            assert png.tobytes() == im.tobytes()
+
+        with pytest.raises(EOFError):
+            im.seek(8)
+
+
+def test_save():
+    directory_path = "Tests/images/ani/"
+    filenames = ["aero_busy_0.png", "aero_busy_8.png",
+                 "posy_busy_0.png", "posy_busy_24.png",
+                 "stopwtch_0.png", "stopwtch_5.png"]
+
+    images = [Image.open(directory_path + filename) for filename in filenames]
+
+    with BytesIO() as output:
+        images[0].save(output, append_images=[images[1]], seq=[
+                       0, 1], rate=[5, 10], format="ANI")
+
+        with Image.open(output, formats=["ANI"]) as im:
+            assert im.tobytes() == images[0].tobytes()
+            im.seek(1)
+            assert im.tobytes() == images[1].tobytes()
+            assert im.info['seq'] == [0, 1]
+            assert im.info['rate'] == [5, 10]
+
+    with BytesIO() as output:
+        images[2].save(output, append_images=[images[3]], seq=[
+                       1, 0], rate=[2, 2], format="ANI", sizes=[(96, 96)])
+
+        with Image.open(output, formats=["ANI"]) as im:
+            assert im.tobytes() == images[2].tobytes()
+            im.seek(1)
+            assert im.tobytes() == images[3].tobytes()
+            assert im.info['seq'] == [1, 0]
+            assert im.info['rate'] == [2, 2]
+
+    with BytesIO() as output:
+        images[4].save(output, append_images=[images[5]], seq=[
+                       0, 1], rate=[3, 4], format="ANI")
+
+        with Image.open(output, formats=["ANI"]) as im:
+            assert im.tobytes() == images[4].tobytes()
+            im.seek(1)
+            assert im.tobytes() == images[5].tobytes()
+            assert im.info['seq'] == [0, 1]
+            assert im.info['rate'] == [3, 4]
+
+    with BytesIO() as output:
+        images[0].save(output, append_images=images[1:],
+                       seq=[0, 2, 4, 1, 3, 5, 0, 1, 0, 1],
+                       rate=[1, 2, 3, 1, 2, 3, 1, 2, 3, 4],
+                       format="ANI", sizes=[(32, 32)])
+
+        with Image.open(output, formats=["ANI"]) as im:
+            assert im.info['frames'] == 6
+            assert im.info['seq'] == [0, 2, 4, 1, 3, 5, 0, 1, 0, 1]
+            assert im.info['rate'] == [1, 2, 3, 1, 2, 3, 1, 2, 3, 4]
+            assert im.size == (32, 32)
+
+            im.seek(4)
+            assert im.tobytes() == images[4].tobytes()
+
+    with BytesIO() as output:
+        with pytest.raises(ValueError):
+            images[0].save(output, append_images=images[1:], seq=[
+                0, 1, 8, 1, 2], rate=[1, 1, 1, 1, 1], format="ANI", sizes=[(32, 32)])
+
+        with pytest.raises(ValueError):
+            images[0].save(output, append_images=images[1:], seq=[
+                0, 1, 1, 1, 2], rate=[1, 1, 1, 1], format="ANI", sizes=[(32, 32)])
+
+        with pytest.raises(ValueError):
+            images[0].save(output, append_images=images[1:], rate=[1, 1, 1, 1], format="ANI", sizes=[(32, 32)])
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 99d59f0681e..d4ec37da41f 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -15,22 +15,21 @@ def _accept(s):
 def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
     fp.write(b"\0\0\2\0")
     bmp = True
-    s = im.encoderinfo.get(
+    s = info.get(
         "sizes",
         [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
     )
-    h = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(s))])
+    h = info.get("hotspots", [(0, 0) for _ in range(len(s))])
 
     if len(h) != len(s):
         raise ValueError("Number of hotspots must be equal to number of cursor sizes")
 
     # sort and remove duplicate sizes
-    sizes = []
-    hotspots = []
-    for size, hotspot in zip(*sorted(zip(s, h), lambda x: x[0])):
+    sizes, hotspots = [], []
+    for size, hotspot in sorted(zip(s, h), key=lambda x: x[0]):
         if size not in sizes:
             sizes.append(size)
-            hotspots.append(hotspots)
+            hotspots.append(hotspot)
 
     frames = []
     width, height = im.size
@@ -72,6 +71,7 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
         image_bytes = image_io.read()
         if bmp:
             image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
+            
         bytes_len = len(image_bytes)
         fp.write(o32(bytes_len))  # dwBytesInRes(4)
         fp.write(o32(offset))  # dwImageOffset(4)
@@ -121,7 +121,8 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
     list_offset = fp.tell()
     fp.write(b"fram")
 
-    frames = [im] + im.encoderinfo.get("append_images", [])
+    frames = [im]
+    frames.extend(im.encoderinfo.get("append_images", []))
     for frame in frames:
         fp.write(b"icon" + o32(0))
         icon_offset = fp.tell()
@@ -342,7 +343,7 @@ def __init__(self, buf: BytesIO) -> None:
 
     def frame(self, frame):
         if frame > self.anih["nFrames"]:
-            raise ValueError("Frame index out of animation bounds")
+            raise EOFError("Frame index out of animation bounds")
 
         offset, size = self.image_data[frame].values()
         self.buf.seek(offset)
@@ -392,6 +393,7 @@ def _open(self):
         self.info["frames"] = self.ani.anih["nFrames"]
 
         self.frame = 0
+        self._min_frame = 0
         self.seek(0)
         self.size = self.im.size
 
@@ -413,8 +415,9 @@ def load(self):
         self.mode = im.mode
 
     def seek(self, frame):
-        if frame > self.info["frames"]:
-            raise ValueError("Frame index out of animation bounds")
+        
+        if frame > self.info["frames"] - 1 or frame < 0:
+            raise EOFError("Frame index out of animation bounds")
 
         self.frame = frame
         self.load()

From 35475914b9f49dab0d23a9082a735c0f0a68ab2e Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 27 Sep 2022 18:17:44 +0000
Subject: [PATCH 27/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 Tests/test_file_ani.py    | 91 +++++++++++++++++++++++++++------------
 Tests/test_file_cur.py    | 43 ++++++++++++------
 src/PIL/AniImagePlugin.py |  4 +-
 3 files changed, 95 insertions(+), 43 deletions(-)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index cd7703164db..9d5bfd6498b 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -1,5 +1,7 @@
 from io import BytesIO
+
 import pytest
+
 from PIL import Image
 
 
@@ -65,55 +67,72 @@ def test_stopwtch():
 
 def test_save():
     directory_path = "Tests/images/ani/"
-    filenames = ["aero_busy_0.png", "aero_busy_8.png",
-                 "posy_busy_0.png", "posy_busy_24.png",
-                 "stopwtch_0.png", "stopwtch_5.png"]
+    filenames = [
+        "aero_busy_0.png",
+        "aero_busy_8.png",
+        "posy_busy_0.png",
+        "posy_busy_24.png",
+        "stopwtch_0.png",
+        "stopwtch_5.png",
+    ]
 
     images = [Image.open(directory_path + filename) for filename in filenames]
 
     with BytesIO() as output:
-        images[0].save(output, append_images=[images[1]], seq=[
-                       0, 1], rate=[5, 10], format="ANI")
+        images[0].save(
+            output, append_images=[images[1]], seq=[0, 1], rate=[5, 10], format="ANI"
+        )
 
         with Image.open(output, formats=["ANI"]) as im:
             assert im.tobytes() == images[0].tobytes()
             im.seek(1)
             assert im.tobytes() == images[1].tobytes()
-            assert im.info['seq'] == [0, 1]
-            assert im.info['rate'] == [5, 10]
+            assert im.info["seq"] == [0, 1]
+            assert im.info["rate"] == [5, 10]
 
     with BytesIO() as output:
-        images[2].save(output, append_images=[images[3]], seq=[
-                       1, 0], rate=[2, 2], format="ANI", sizes=[(96, 96)])
+        images[2].save(
+            output,
+            append_images=[images[3]],
+            seq=[1, 0],
+            rate=[2, 2],
+            format="ANI",
+            sizes=[(96, 96)],
+        )
 
         with Image.open(output, formats=["ANI"]) as im:
             assert im.tobytes() == images[2].tobytes()
             im.seek(1)
             assert im.tobytes() == images[3].tobytes()
-            assert im.info['seq'] == [1, 0]
-            assert im.info['rate'] == [2, 2]
+            assert im.info["seq"] == [1, 0]
+            assert im.info["rate"] == [2, 2]
 
     with BytesIO() as output:
-        images[4].save(output, append_images=[images[5]], seq=[
-                       0, 1], rate=[3, 4], format="ANI")
+        images[4].save(
+            output, append_images=[images[5]], seq=[0, 1], rate=[3, 4], format="ANI"
+        )
 
         with Image.open(output, formats=["ANI"]) as im:
             assert im.tobytes() == images[4].tobytes()
             im.seek(1)
             assert im.tobytes() == images[5].tobytes()
-            assert im.info['seq'] == [0, 1]
-            assert im.info['rate'] == [3, 4]
+            assert im.info["seq"] == [0, 1]
+            assert im.info["rate"] == [3, 4]
 
     with BytesIO() as output:
-        images[0].save(output, append_images=images[1:],
-                       seq=[0, 2, 4, 1, 3, 5, 0, 1, 0, 1],
-                       rate=[1, 2, 3, 1, 2, 3, 1, 2, 3, 4],
-                       format="ANI", sizes=[(32, 32)])
+        images[0].save(
+            output,
+            append_images=images[1:],
+            seq=[0, 2, 4, 1, 3, 5, 0, 1, 0, 1],
+            rate=[1, 2, 3, 1, 2, 3, 1, 2, 3, 4],
+            format="ANI",
+            sizes=[(32, 32)],
+        )
 
         with Image.open(output, formats=["ANI"]) as im:
-            assert im.info['frames'] == 6
-            assert im.info['seq'] == [0, 2, 4, 1, 3, 5, 0, 1, 0, 1]
-            assert im.info['rate'] == [1, 2, 3, 1, 2, 3, 1, 2, 3, 4]
+            assert im.info["frames"] == 6
+            assert im.info["seq"] == [0, 2, 4, 1, 3, 5, 0, 1, 0, 1]
+            assert im.info["rate"] == [1, 2, 3, 1, 2, 3, 1, 2, 3, 4]
             assert im.size == (32, 32)
 
             im.seek(4)
@@ -121,12 +140,30 @@ def test_save():
 
     with BytesIO() as output:
         with pytest.raises(ValueError):
-            images[0].save(output, append_images=images[1:], seq=[
-                0, 1, 8, 1, 2], rate=[1, 1, 1, 1, 1], format="ANI", sizes=[(32, 32)])
+            images[0].save(
+                output,
+                append_images=images[1:],
+                seq=[0, 1, 8, 1, 2],
+                rate=[1, 1, 1, 1, 1],
+                format="ANI",
+                sizes=[(32, 32)],
+            )
 
         with pytest.raises(ValueError):
-            images[0].save(output, append_images=images[1:], seq=[
-                0, 1, 1, 1, 2], rate=[1, 1, 1, 1], format="ANI", sizes=[(32, 32)])
+            images[0].save(
+                output,
+                append_images=images[1:],
+                seq=[0, 1, 1, 1, 2],
+                rate=[1, 1, 1, 1],
+                format="ANI",
+                sizes=[(32, 32)],
+            )
 
         with pytest.raises(ValueError):
-            images[0].save(output, append_images=images[1:], rate=[1, 1, 1, 1], format="ANI", sizes=[(32, 32)])
+            images[0].save(
+                output,
+                append_images=images[1:],
+                rate=[1, 1, 1, 1],
+                format="ANI",
+                sizes=[(32, 32)],
+            )
diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index 07eae8cadd9..165cbb09b70 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -1,13 +1,14 @@
+from io import BytesIO
+
 import pytest
 
-from io import BytesIO
 from PIL import CurImagePlugin, Image
 
 
 def test_deerstalker():
     with Image.open("Tests/images/cur/deerstalker.cur") as im:
         assert im.size == (32, 32)
-        assert im.info['hotspots'] == [(0, 0)]
+        assert im.info["hotspots"] == [(0, 0)]
         assert isinstance(im, CurImagePlugin.CurImageFile)
         # Check some pixel colors to ensure image is loaded properly
         assert im.getpixel((10, 1)) == (0, 0, 0, 0)
@@ -18,8 +19,8 @@ def test_deerstalker():
 def test_posy_link():
     with Image.open("Tests/images/cur/posy_link.cur") as im:
         assert im.size == (128, 128)
-        assert im.info['sizes'] == [(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)]
-        assert im.info['hotspots'] == [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
+        assert im.info["sizes"] == [(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)]
+        assert im.info["hotspots"] == [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
         # check some pixel colors
         assert im.getpixel((0, 0)) == (0, 0, 0, 0)
         assert im.getpixel((20, 20)) == (0, 0, 0, 255)
@@ -69,8 +70,13 @@ def test_save_win98_arrow():
     with Image.open("Tests/images/cur/win98_arrow.png") as im:
         # save the data
         with BytesIO() as output:
-            im.save(output, format="CUR", sizes=[(32, 32)], hotspots=[
-                    (10, 10)], bitmap_format="bmp")
+            im.save(
+                output,
+                format="CUR",
+                sizes=[(32, 32)],
+                hotspots=[(10, 10)],
+                bitmap_format="bmp",
+            )
 
             with Image.open(output) as im2:
                 assert im.tobytes() == im2.tobytes()
@@ -92,27 +98,36 @@ def test_save_posy_link():
     with Image.open("Tests/images/cur/posy_link.png") as im:
         # save the data
         with BytesIO() as output:
-            im.save(output, sizes=sizes, hotspots=hotspots,
-                    format="CUR", bitmap_format="bmp")
+            im.save(
+                output,
+                sizes=sizes,
+                hotspots=hotspots,
+                format="CUR",
+                bitmap_format="bmp",
+            )
 
             # make sure saved output is readable
             # and sizes/hotspots are correct
             with Image.open(output, formats=["CUR"]) as im2:
                 assert (128, 128) == im2.size
-                assert sizes == im2.info['sizes']
+                assert sizes == im2.info["sizes"]
 
         with BytesIO() as output:
-            im.save(output, sizes=sizes[3:], hotspots=hotspots[3:],
-                    format="CUR")
+            im.save(output, sizes=sizes[3:], hotspots=hotspots[3:], format="CUR")
 
             # make sure saved output is readable
             # and sizes/hotspots are correct
             with Image.open(output, formats=["CUR"]) as im2:
                 assert (48, 48) == im2.size
-                assert sizes[3:] == im2.info['sizes']
+                assert sizes[3:] == im2.info["sizes"]
 
             # make sure error is thrown when size and hotspot len's
             # don't match
             with pytest.raises(ValueError):
-                im.save(output, sizes=sizes[2:], hotspots=hotspots[3:],
-                        format="CUR", bitmap_format="bmp")
+                im.save(
+                    output,
+                    sizes=sizes[2:],
+                    hotspots=hotspots[3:],
+                    format="CUR",
+                    bitmap_format="bmp",
+                )
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index d4ec37da41f..72f4543f925 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -71,7 +71,7 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
         image_bytes = image_io.read()
         if bmp:
             image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
-            
+
         bytes_len = len(image_bytes)
         fp.write(o32(bytes_len))  # dwBytesInRes(4)
         fp.write(o32(offset))  # dwImageOffset(4)
@@ -415,7 +415,7 @@ def load(self):
         self.mode = im.mode
 
     def seek(self, frame):
-        
+
         if frame > self.info["frames"] - 1 or frame < 0:
             raise EOFError("Frame index out of animation bounds")
 

From 915df6c7cf79007849a5c78972f5e81a6497850a Mon Sep 17 00:00:00 2001
From: jlwoolf <jlwoolf@protonmail.com>
Date: Tue, 27 Sep 2022 12:30:32 -0600
Subject: [PATCH 28/53] fixed bug regarding bitmap (added alpha flag)

---
 src/PIL/AniImagePlugin.py | 6 ++++--
 src/PIL/BmpImagePlugin.py | 7 +++++--
 src/PIL/CurImagePlugin.py | 6 ++++--
 3 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index d4ec37da41f..9e25cded48f 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -58,13 +58,15 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
 
         image_io = BytesIO()
         if bmp:
-            frame.save(image_io, "dib")
-
             if bits != 32:
                 and_mask = Image.new("1", size)
                 ImageFile._save(
                     and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
                 )
+            else:
+                frame.alpha = True
+
+            frame.save(image_io, "dib")
         else:
             frame.save(image_io, "png")
         image_io.seek(0)
diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index bc81f3274c1..8e69acd3605 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -209,8 +209,11 @@ def _bitmap(self, header=0, offset=0):
             else:
                 raise OSError("Unsupported BMP bitfields layout")
         elif file_info["compression"] == self.RAW:
-            if file_info["bits"] == 32:  # 32-bit has transparency
-                raw_mode, self.mode = "BGRA", "RGBA"
+            try:
+                if file_info["bits"] == 32 and self.alpha:
+                    raw_mode, self.mode = "BGRA", "RGBA"
+            except AttributeError:
+                pass
         elif file_info["compression"] == self.RLE8:
             decoder_name = "bmp_rle"
         else:
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 9e60ecc1fbf..ba11d08cce9 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -76,13 +76,15 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
 
         image_io = BytesIO()
         if bmp:
-            frame.save(image_io, "dib")
-
             if bits != 32:
                 and_mask = Image.new("1", size)
                 ImageFile._save(
                     and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
                 )
+            else:
+                frame.alpha = True
+
+            frame.save(image_io, "dib")
         else:
             frame.save(image_io, "png")
         image_io.seek(0)

From e8684d76116a743db6431c93d353f8963cc5737d Mon Sep 17 00:00:00 2001
From: Jonathan Woolf <60640764+jlwoolf@users.noreply.github.com>
Date: Mon, 3 Oct 2022 11:37:02 -0600
Subject: [PATCH 29/53] Update src/PIL/CurImagePlugin.py

Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com>
---
 src/PIL/CurImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index ba11d08cce9..add2cdc3af6 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -109,7 +109,7 @@ def _accept(prefix):
 ##
 # Image plugin for Windows Cursor files.
 class CurFile(IcoImagePlugin.IcoFile):
-    def __init__(self, buf: BytesIO):
+    def __init__(self, buf: BytesIO()):
         """
         Parse image from file-like object containing cur file data
         """

From 792f5191f972ecbf83a8cb4fa13cb004e76a8fc6 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Thu, 20 Oct 2022 22:14:22 +1100
Subject: [PATCH 30/53] Do not use PyAccess after re-assigning im

---
 src/PIL/IcoImagePlugin.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py
index 17b9855a0a5..93b9dfdea7b 100644
--- a/src/PIL/IcoImagePlugin.py
+++ b/src/PIL/IcoImagePlugin.py
@@ -327,6 +327,7 @@ def load(self):
         # if tile is PNG, it won't really be loaded yet
         im.load()
         self.im = im.im
+        self.pyaccess = None
         self.mode = im.mode
         if im.size != self.size:
             warnings.warn("Image was not the expected size")

From c5e8d12ddd49a57fa47275cb026866e1ae10acdc Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Sat, 25 Feb 2023 10:16:19 +0000
Subject: [PATCH 31/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/PIL/AniImagePlugin.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index c407824e3c9..091827fd461 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -426,7 +426,6 @@ def load(self):
         self.mode = im.mode
 
     def seek(self, frame):
-
         if frame > self.info["frames"] - 1 or frame < 0:
             msg = "Frame index out of animation bounds"
             raise EOFError(msg)

From 5f6d3f19160c72980a45ea86526409eb829a096a Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 2 Sep 2023 19:24:33 +1000
Subject: [PATCH 32/53] mode is read-only

---
 src/PIL/AniImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 091827fd461..9599410cae8 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -423,7 +423,7 @@ def load(self):
         self.info["sizes"] = im.info["sizes"]
         self.info["hotspots"] = im.info["hotspots"]
         self.im = im.im
-        self.mode = im.mode
+        self._mode = im.mode
 
     def seek(self, frame):
         if frame > self.info["frames"] - 1 or frame < 0:

From 227c84fd923aac520ad2ce45ca219624cb45c821 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 9 Dec 2023 15:02:08 +1100
Subject: [PATCH 33/53] Removed unnecessary list comprehension

---
 src/PIL/AniImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 9599410cae8..507542530ce 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -345,7 +345,7 @@ def __init__(self, buf: BytesIO) -> None:
             raise SyntaxError(msg)
 
         if self.seq is None:
-            self.seq = [i for i in range(self.anih["nFrames"])]
+            self.seq = list(range(self.anih["nFrames"]))
 
         if self.rate is None:
             self.rate = [self.anih["iDispRate"] for i in range(self.anih["nFrames"])]

From 76179a2ea1e2e51f06334d79f2327c4ef293793f Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Sun, 24 Dec 2023 08:41:59 +0000
Subject: [PATCH 34/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 Tests/test_file_ani.py    | 1 +
 src/PIL/AniImagePlugin.py | 2 ++
 src/PIL/CurImagePlugin.py | 1 +
 3 files changed, 4 insertions(+)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index 9d5bfd6498b..474050c8ca6 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -1,3 +1,4 @@
+from __future__ import annotations
 from io import BytesIO
 
 import pytest
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 507542530ce..e66d5ec2384 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import struct
 from io import BytesIO
 
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index d0cf2113770..4c41dfd9c38 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -16,6 +16,7 @@
 # See the README file for information on usage and redistribution.
 #
 from __future__ import annotations
+
 from io import BytesIO
 
 from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile

From 4524742c67ef020bb861f567f552cfb5340a10db Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Sun, 21 Jan 2024 05:33:11 +0000
Subject: [PATCH 35/53] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 Tests/test_file_ani.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index 474050c8ca6..dc538b13fcb 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -1,4 +1,5 @@
 from __future__ import annotations
+
 from io import BytesIO
 
 import pytest

From 26be2ee1e459125ea8dfbf6a5476814215783639 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 26 Apr 2025 15:47:35 +1000
Subject: [PATCH 36/53] Lint fixes

---
 src/PIL/AniImagePlugin.py | 49 ++++++++++++-----------
 src/PIL/CurImagePlugin.py | 82 ++++++++++++++++++++++-----------------
 2 files changed, 74 insertions(+), 57 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index e66d5ec2384..7e70decc439 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -2,6 +2,7 @@
 
 import struct
 from io import BytesIO
+from typing import IO, Any
 
 from PIL import BmpImagePlugin, CurImagePlugin, Image, ImageFile
 from PIL._binary import i32le as i32
@@ -10,11 +11,11 @@
 from PIL._binary import o32le as o32
 
 
-def _accept(s):
-    return s[:4] == b"RIFF"
+def _accept(prefix: bytes) -> bool:
+    return prefix[:4] == b"RIFF"
 
 
-def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
+def _save_frame(im: Image.Image, fp: BytesIO, info: dict[str, Any]) -> None:
     fp.write(b"\0\0\2\0")
     bmp = True
     s = info.get(
@@ -64,7 +65,9 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
             if bits != 32:
                 and_mask = Image.new("1", size)
                 ImageFile._save(
-                    and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
+                    and_mask,
+                    image_io,
+                    [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
                 )
             else:
                 frame.alpha = True
@@ -87,7 +90,7 @@ def _save_frame(im: Image.Image, fp: BytesIO, filename: str, info: dict):
         fp.seek(current)
 
 
-def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
+def _write_single_frame(im: Image.Image, fp: IO[bytes]) -> None:
     fp.write(b"anih")
     anih = o32(36) + o32(36) + (o32(1) * 2) + (o32(0) * 4) + o32(60) + o32(1)
     fp.write(anih)
@@ -99,7 +102,7 @@ def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
     fp.write(b"icon" + o32(0))
     icon_offset = fp.tell()
     with BytesIO(b"") as icon_fp:
-        _save_frame(im, icon_fp, filename, im.encoderinfo)
+        _save_frame(im, icon_fp, im.encoderinfo)
         icon_fp.seek(0)
         icon_data = icon_fp.read()
 
@@ -117,7 +120,7 @@ def _write_single_frame(im: Image.Image, fp: BytesIO, filename: str):
     fp.seek(fram_end)
 
 
-def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
+def _write_multiple_frames(im: Image.Image, fp: IO[bytes]) -> None:
     anih_offset = fp.tell()
     fp.write(b"anih" + o32(36))
     fp.write(o32(0) * 9)
@@ -132,7 +135,7 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
         fp.write(b"icon" + o32(0))
         icon_offset = fp.tell()
         with BytesIO(b"") as icon_fp:
-            _save_frame(frame, icon_fp, filename, im.encoderinfo)
+            _save_frame(frame, icon_fp, im.encoderinfo)
             icon_fp.seek(0)
             icon_data = icon_fp.read()
 
@@ -216,7 +219,7 @@ def _write_multiple_frames(im: Image.Image, fp: BytesIO, filename: str):
     fp.seek(fram_end)
 
 
-def _write_info(im: Image.Image, fp: BytesIO, filename: str):
+def _write_info(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
     fp.write(b"LIST" + o32(0))
     list_offset = fp.tell()
 
@@ -263,7 +266,7 @@ def _write_info(im: Image.Image, fp: BytesIO, filename: str):
     fp.seek(info_end)
 
 
-def _save(im: Image.Image, fp: BytesIO, filename: str):
+def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
     fp.write(b"RIFF\x00\x00\x00\x00")
     riff_offset = fp.tell()
 
@@ -272,9 +275,9 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
 
     frames = im.encoderinfo.get("append_images", [])
     if frames:
-        _write_multiple_frames(im, fp, filename)
+        _write_multiple_frames(im, fp)
     else:
-        _write_single_frame(im, fp, filename)
+        _write_single_frame(im, fp)
     pass
 
     riff_end = fp.tell()
@@ -352,7 +355,8 @@ def __init__(self, buf: BytesIO) -> None:
         if self.rate is None:
             self.rate = [self.anih["iDispRate"] for i in range(self.anih["nFrames"])]
 
-    def frame(self, frame):
+    def frame(self, frame: int) -> CurImagePlugin.CurImageFile:
+        assert self.anih is not None
         if frame > self.anih["nFrames"]:
             msg = "Frame index out of animation bounds"
             raise EOFError(msg)
@@ -361,13 +365,12 @@ def frame(self, frame):
         self.buf.seek(offset)
         data = self.buf.read(size)
 
-        im = CurImagePlugin.CurImageFile(BytesIO(data))
-        return im
+        return CurImagePlugin.CurImageFile(BytesIO(data))
 
-    def sizes(self):
+    def sizes(self) -> list[int]:
         return [data["size"] for data in self.image_data]
 
-    def hotspots(self):
+    def hotspots(self) -> None:
         pass
 
 
@@ -398,10 +401,12 @@ class AniImageFile(ImageFile.ImageFile):
     format = "ANI"
     format_description = "Windows Animated Cursor"
 
-    def _open(self):
+    def _open(self) -> None:
         self.ani = AniFile(self.fp)
         self.info["seq"] = self.ani.seq
         self.info["rate"] = self.ani.rate
+
+        assert self.ani.anih is not None
         self.info["frames"] = self.ani.anih["nFrames"]
 
         self.frame = 0
@@ -410,24 +415,24 @@ def _open(self):
         self.size = self.im.size
 
     @property
-    def size(self):
+    def size(self) -> tuple[int, int]:
         return self._size
 
     @size.setter
-    def size(self, value):
+    def size(self, value: tuple[int, int]) -> None:
         if value not in self.info["sizes"]:
             msg = "This is not one of the allowed sizes of this image"
             raise ValueError(msg)
         self._size = value
 
-    def load(self):
+    def load(self) -> None:
         im = self.ani.frame(self.frame)
         self.info["sizes"] = im.info["sizes"]
         self.info["hotspots"] = im.info["hotspots"]
         self.im = im.im
         self._mode = im.mode
 
-    def seek(self, frame):
+    def seek(self, frame: int) -> None:
         if frame > self.info["frames"] - 1 or frame < 0:
             msg = "Frame index out of animation bounds"
             raise EOFError(msg)
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 1f2c326dd3f..3a897171e12 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 from io import BytesIO
+from typing import IO, NamedTuple
 
 from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile
 from ._binary import i16le as i16
@@ -32,7 +33,7 @@
 _MAGIC = b"\x00\x00\x02\x00"
 
 
-def _save(im: Image.Image, fp: BytesIO, filename: str):
+def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
     fp.write(_MAGIC)
     bmp = im.encoderinfo.get("bitmap_format", "") == "bmp"
     s = im.encoderinfo.get(
@@ -82,7 +83,9 @@ def _save(im: Image.Image, fp: BytesIO, filename: str):
             if bits != 32:
                 and_mask = Image.new("1", size)
                 ImageFile._save(
-                    and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
+                    and_mask,
+                    image_io,
+                    [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
                 )
             else:
                 frame.alpha = True
@@ -109,10 +112,24 @@ def _accept(prefix: bytes) -> bool:
     return prefix.startswith(_MAGIC)
 
 
+class IconHeader(NamedTuple):
+    width: int
+    height: int
+    nb_color: int
+    reserved: int
+    bpp: int
+    x_hotspot: int
+    y_hotspot: int
+    size: int
+    offset: int
+    dim: tuple[int, int]
+    square: int
+
+
 ##
 # Image plugin for Windows Cursor files.
 class CurFile(IcoImagePlugin.IcoFile):
-    def __init__(self, buf: BytesIO()):
+    def __init__(self, buf: IO[bytes]) -> None:
         """
         Parse image from file-like object containing cur file data
         """
@@ -133,24 +150,12 @@ def __init__(self, buf: BytesIO()):
         for _ in range(self.nb_items):
             s = buf.read(16)
 
-            icon_header = {
-                "width": s[0],
-                "height": s[1],
-                "nb_color": s[2],  # No. of colors in image (0 if >=8bpp)
-                "reserved": s[3],
-                "x_hotspot": i16(s, 4),
-                "y_hotspot": i16(s, 6),
-                "size": i32(s, 8),
-                "offset": i32(s, 12),
-            }
-
             # See Wikipedia
-            for j in ("width", "height"):
-                if not icon_header[j]:
-                    icon_header[j] = 256
+            width = s[0] or 256
+            height = s[1] or 256
 
-            icon_header["dim"] = (icon_header["width"], icon_header["height"])
-            icon_header["square"] = icon_header["width"] * icon_header["height"]
+            size = i32(s, 8)
+            square = width * height
 
             # TODO: This needs further investigation. Cursor files do not really
             # specify their bpp like ICO's as those bits are used for the y_hotspot.
@@ -158,28 +163,35 @@ def __init__(self, buf: BytesIO()):
             # of pixels * 1bpp) and dividing by the number of pixels. This seems
             # to work well so far.
             BITMAP_INFO_HEADER_SIZE = 40
-            bpp_without_and = (
-                (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8
-            ) // icon_header["square"]
-
+            bpp_without_and = ((size - BITMAP_INFO_HEADER_SIZE) * 8) // square
             if bpp_without_and != 32:
-                icon_header["bpp"] = (
-                    (icon_header["size"] - BITMAP_INFO_HEADER_SIZE) * 8
-                    - icon_header["square"]
-                ) // icon_header["square"]
+                bpp = ((size - BITMAP_INFO_HEADER_SIZE) * 8 - square) // square
             else:
-                icon_header["bpp"] = bpp_without_and
+                bpp = bpp_without_and
+
+            icon_header = IconHeader(
+                width=width,
+                height=height,
+                nb_color=s[2],  # No. of colors in image (0 if >=8bpp)
+                reserved=s[3],
+                bpp=bpp,
+                x_hotspot=i16(s, 4),
+                y_hotspot=i16(s, 6),
+                size=size,
+                offset=i32(s, 12),
+                dim=(width, height),
+                square=square,
+            )
 
             self.entry.append(icon_header)
 
-        self.entry = sorted(self.entry, key=lambda x: x["square"])
-        self.entry.reverse()
+        self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
 
-    def sizes(self):
-        return [(h["width"], h["height"]) for h in self.entry]
+    def sizes(self) -> list[tuple[int, int]]:
+        return [(h.width, h.height) for h in self.entry]
 
-    def hotspots(self):
-        return [(h["x_hotspot"], h["y_hotspot"]) for h in self.entry]
+    def hotspots(self) -> list[tuple[int, int]]:
+        return [(h.x_hotspot, h.y_hotspot) for h in self.entry]
 
 
 class CurImageFile(IcoImagePlugin.IcoImageFile):
@@ -213,7 +225,7 @@ def _open(self) -> None:
         self.info["sizes"] = self.ico.sizes()
         self.info["hotspots"] = self.ico.hotspots()
         if len(self.ico.entry) > 0:
-            self.size = self.ico.entry[0]["dim"]
+            self.size = self.ico.entry[0].dim
         else:
             msg = "No cursors were found"
             raise TypeError(msg)

From 8eba937d996e56428a648c088783ffe111b576df Mon Sep 17 00:00:00 2001
From: Andrew Murray <3112309+radarhere@users.noreply.github.com>
Date: Fri, 9 May 2025 20:53:55 +1000
Subject: [PATCH 37/53] Replace slice and comparison with startswith

---
 src/PIL/AniImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 7e70decc439..9f1c518c457 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -12,7 +12,7 @@
 
 
 def _accept(prefix: bytes) -> bool:
-    return prefix[:4] == b"RIFF"
+    return prefix.startswith(b"RIFF")
 
 
 def _save_frame(im: Image.Image, fp: BytesIO, info: dict[str, Any]) -> None:

From 15307955f747637f03b8d1ed1d880703532d994f Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 26 Apr 2025 15:56:15 +1000
Subject: [PATCH 38/53] Use IcoFile sizes method

---
 Tests/test_file_cur.py    | 8 ++++----
 src/PIL/CurImagePlugin.py | 3 ---
 2 files changed, 4 insertions(+), 7 deletions(-)

diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index 586e36c539b..c23bb6a1ab7 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -21,7 +21,7 @@ def test_deerstalker() -> None:
 def test_posy_link():
     with Image.open("Tests/images/cur/posy_link.cur") as im:
         assert im.size == (128, 128)
-        assert im.info["sizes"] == [(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)]
+        assert im.info["sizes"] == {(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)}
         assert im.info["hotspots"] == [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
         # check some pixel colors
         assert im.getpixel((0, 0)) == (0, 0, 0, 0)
@@ -89,7 +89,7 @@ def test_save_win98_arrow():
             # check default save params
             with Image.open(output) as im2:
                 assert im2.size == (32, 32)
-                assert im2.info["sizes"] == [(32, 32), (24, 24), (16, 16)]
+                assert im2.info["sizes"] == {(32, 32), (24, 24), (16, 16)}
                 assert im2.info["hotspots"] == [(0, 0), (0, 0), (0, 0)]
 
 
@@ -112,7 +112,7 @@ def test_save_posy_link():
             # and sizes/hotspots are correct
             with Image.open(output, formats=["CUR"]) as im2:
                 assert (128, 128) == im2.size
-                assert sizes == im2.info["sizes"]
+                assert set(sizes) == im2.info["sizes"]
 
         with BytesIO() as output:
             im.save(output, sizes=sizes[3:], hotspots=hotspots[3:], format="CUR")
@@ -121,7 +121,7 @@ def test_save_posy_link():
             # and sizes/hotspots are correct
             with Image.open(output, formats=["CUR"]) as im2:
                 assert (48, 48) == im2.size
-                assert sizes[3:] == im2.info["sizes"]
+                assert set(sizes[3:]) == im2.info["sizes"]
 
             # make sure error is thrown when size and hotspot len's
             # don't match
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 3a897171e12..1a9ddf5ea73 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -187,9 +187,6 @@ def __init__(self, buf: IO[bytes]) -> None:
 
         self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
 
-    def sizes(self) -> list[tuple[int, int]]:
-        return [(h.width, h.height) for h in self.entry]
-
     def hotspots(self) -> list[tuple[int, int]]:
         return [(h.x_hotspot, h.y_hotspot) for h in self.entry]
 

From 0842985ebcf100da5a1a0bcbbe0d2dadaf5620ce Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Fri, 9 May 2025 23:42:05 +1000
Subject: [PATCH 39/53] Added type hints

---
 Tests/test_file_ani.py |  8 ++++----
 Tests/test_file_cur.py | 34 +++++++++++++++++-----------------
 2 files changed, 21 insertions(+), 21 deletions(-)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index dc538b13fcb..807ef0b6356 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -7,7 +7,7 @@
 from PIL import Image
 
 
-def test_aero_busy():
+def test_aero_busy() -> None:
     with Image.open("Tests/images/ani/aero_busy.ani") as im:
         assert im.size == (64, 64)
         assert im.info["frames"] == 18
@@ -26,7 +26,7 @@ def test_aero_busy():
             im.seek(18)
 
 
-def test_posy_busy():
+def test_posy_busy() -> None:
     with Image.open("Tests/images/ani/posy_busy.ani") as im:
         assert im.size == (96, 96)
         assert im.info["frames"] == 77
@@ -42,7 +42,7 @@ def test_posy_busy():
             im.seek(77)
 
 
-def test_stopwtch():
+def test_stopwtch() -> None:
     with Image.open("Tests/images/ani/stopwtch.ani") as im:
         assert im.size == (32, 32)
         assert im.info["frames"] == 8
@@ -67,7 +67,7 @@ def test_stopwtch():
             im.seek(8)
 
 
-def test_save():
+def test_save() -> None:
     directory_path = "Tests/images/ani/"
     filenames = [
         "aero_busy_0.png",
diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index c23bb6a1ab7..9246b1101d0 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -18,7 +18,7 @@ def test_deerstalker() -> None:
         assert im.getpixel((16, 16)) == (84, 87, 86, 255)
 
 
-def test_posy_link():
+def test_posy_link() -> None:
     with Image.open("Tests/images/cur/posy_link.cur") as im:
         assert im.size == (128, 128)
         assert im.info["sizes"] == {(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)}
@@ -34,7 +34,7 @@ def test_posy_link():
         assert im.getpixel((10, 10)) == (191, 191, 191, 255)
 
 
-def test_stopwtch():
+def test_stopwtch() -> None:
     with Image.open("Tests/images/cur/stopwtch.cur") as im:
         assert im.size == (32, 32)
         assert im.info["hotspots"] == [(16, 19)]
@@ -43,7 +43,7 @@ def test_stopwtch():
         assert im.getpixel((8, 16)) == (255, 0, 0, 255)
 
 
-def test_win98_arrow():
+def test_win98_arrow() -> None:
     with Image.open("Tests/images/cur/win98_arrow.cur") as im:
         assert im.size == (32, 32)
         assert im.info["hotspots"] == [(10, 10)]
@@ -68,7 +68,7 @@ def test_invalid_file() -> None:
             cur._open()
 
 
-def test_save_win98_arrow():
+def test_save_win98_arrow() -> None:
     with Image.open("Tests/images/cur/win98_arrow.png") as im:
         # save the data
         with BytesIO() as output:
@@ -80,20 +80,20 @@ def test_save_win98_arrow():
                 bitmap_format="bmp",
             )
 
-            with Image.open(output) as im2:
-                assert im.tobytes() == im2.tobytes()
+            with Image.open(output) as reloaded:
+                assert im.tobytes() == reloaded.tobytes()
 
         with BytesIO() as output:
             im.save(output, format="CUR")
 
             # check default save params
-            with Image.open(output) as im2:
-                assert im2.size == (32, 32)
-                assert im2.info["sizes"] == {(32, 32), (24, 24), (16, 16)}
-                assert im2.info["hotspots"] == [(0, 0), (0, 0), (0, 0)]
+            with Image.open(output) as reloaded:
+                assert reloaded.size == (32, 32)
+                assert reloaded.info["sizes"] == {(32, 32), (24, 24), (16, 16)}
+                assert reloaded.info["hotspots"] == [(0, 0), (0, 0), (0, 0)]
 
 
-def test_save_posy_link():
+def test_save_posy_link() -> None:
     sizes = [(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)]
     hotspots = [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
 
@@ -110,18 +110,18 @@ def test_save_posy_link():
 
             # make sure saved output is readable
             # and sizes/hotspots are correct
-            with Image.open(output, formats=["CUR"]) as im2:
-                assert (128, 128) == im2.size
-                assert set(sizes) == im2.info["sizes"]
+            with Image.open(output, formats=["CUR"]) as reloaded:
+                assert (128, 128) == reloaded.size
+                assert set(sizes) == reloaded.info["sizes"]
 
         with BytesIO() as output:
             im.save(output, sizes=sizes[3:], hotspots=hotspots[3:], format="CUR")
 
             # make sure saved output is readable
             # and sizes/hotspots are correct
-            with Image.open(output, formats=["CUR"]) as im2:
-                assert (48, 48) == im2.size
-                assert set(sizes[3:]) == im2.info["sizes"]
+            with Image.open(output, formats=["CUR"]) as reloaded:
+                assert (48, 48) == reloaded.size
+                assert set(sizes[3:]) == reloaded.info["sizes"]
 
             # make sure error is thrown when size and hotspot len's
             # don't match

From 0bf71a44ce27cb87fb57fa82eb637fe1bd68d7bb Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 10 May 2025 20:33:29 +1000
Subject: [PATCH 40/53] Use _accept check in _open

---
 src/PIL/AniImagePlugin.py | 32 ++++++++++++++++----------------
 1 file changed, 16 insertions(+), 16 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 9f1c518c457..cab8287d65b 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -278,7 +278,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
         _write_multiple_frames(im, fp)
     else:
         _write_single_frame(im, fp)
-    pass
 
     riff_end = fp.tell()
     fp.seek(riff_offset - 4)
@@ -289,28 +288,29 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
 
 
 class AniFile:
-    def __init__(self, buf: BytesIO) -> None:
+    def __init__(self, fp: BytesIO) -> None:
+        if not _accept(fp.read(4)):
+            SyntaxError("Not an ANI file")
+
         self.image_data = []
 
-        self.buf = buf
+        self.buf = fp
         self.rate = None
         self.seq = None
         self.anih = None
 
-        riff, size, fformat = struct.unpack("<4sI4s", buf.read(12))
-        if riff != b"RIFF":
-            SyntaxError("Not an ANI file")
+        size, fformat = struct.unpack("<I4s", fp.read(8))
 
         self.riff = {"size": size, "fformat": fformat}
 
-        chunkOffset = buf.tell()
+        chunkOffset = fp.tell()
         while chunkOffset < self.riff["size"]:
-            buf.seek(chunkOffset)
-            chunk, size = struct.unpack("<4sI", buf.read(8))
+            fp.seek(chunkOffset)
+            chunk, size = struct.unpack("<4sI", fp.read(8))
             chunkOffset = chunkOffset + size + 8
 
             if chunk == b"anih":
-                s = buf.read(36)
+                s = fp.read(36)
                 self.anih = {
                     "size": i32(s),  # Data structure size (in bytes)
                     "nFrames": i32(s, 4),  # Number of frames
@@ -325,24 +325,24 @@ def __init__(self, buf: BytesIO) -> None:
                 }
 
             if chunk == b"seq ":
-                s = buf.read(size)
+                s = fp.read(size)
                 self.seq = [i32(s, i * 4) for i in range(size // 4)]
 
             if chunk == b"rate":
-                s = buf.read(size)
+                s = fp.read(size)
                 self.rate = [i32(s, i * 4) for i in range(size // 4)]
 
             if chunk == b"LIST":
-                listtype = struct.unpack("<4s", buf.read(4))[0]
+                listtype = struct.unpack("<4s", fp.read(4))[0]
                 if listtype != b"fram":
                     continue
 
                 listOffset = 0
                 while listOffset < size - 8:
-                    _, lSize = struct.unpack("<4sI", buf.read(8))
-                    self.image_data.append({"offset": buf.tell(), "size": lSize})
+                    _, lSize = struct.unpack("<4sI", fp.read(8))
+                    self.image_data.append({"offset": fp.tell(), "size": lSize})
 
-                    buf.read(lSize)
+                    fp.read(lSize)
                     listOffset = listOffset + lSize + 8
 
         if self.anih is None:

From eb8bc462693b4fc139bb42d6b863ebb6d078f3e6 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 10 May 2025 20:35:50 +1000
Subject: [PATCH 41/53] _min_frame is already zero

---
 src/PIL/AniImagePlugin.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index cab8287d65b..d240515ad81 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -410,7 +410,6 @@ def _open(self) -> None:
         self.info["frames"] = self.ani.anih["nFrames"]
 
         self.frame = 0
-        self._min_frame = 0
         self.seek(0)
         self.size = self.im.size
 

From 988abb4288e39f6850a2adfab4dd638039d7756c Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 10 May 2025 20:45:11 +1000
Subject: [PATCH 42/53] Return PixelAccess from load

---
 src/PIL/AniImagePlugin.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index d240515ad81..265ccb61205 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -424,12 +424,13 @@ def size(self, value: tuple[int, int]) -> None:
             raise ValueError(msg)
         self._size = value
 
-    def load(self) -> None:
+    def load(self) -> Image.core.PixelAccess | None:
         im = self.ani.frame(self.frame)
         self.info["sizes"] = im.info["sizes"]
         self.info["hotspots"] = im.info["hotspots"]
         self.im = im.im
         self._mode = im.mode
+        return Image.Image.load(self)
 
     def seek(self, frame: int) -> None:
         if frame > self.info["frames"] - 1 or frame < 0:

From f5d9838e3028c1c1f4d85dedd0b4ba91ee54e565 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 10 May 2025 20:54:46 +1000
Subject: [PATCH 43/53] Do not load at end of seek

---
 src/PIL/AniImagePlugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 265ccb61205..5d78d30244e 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -411,6 +411,7 @@ def _open(self) -> None:
 
         self.frame = 0
         self.seek(0)
+        self.load()
         self.size = self.im.size
 
     @property
@@ -438,7 +439,6 @@ def seek(self, frame: int) -> None:
             raise EOFError(msg)
 
         self.frame = frame
-        self.load()
 
 
 Image.register_open(AniImageFile.format, AniImageFile, _accept)

From ccfc2524b8bb6336877dc513f9ba77a187ad79e4 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 10 May 2025 20:56:56 +1000
Subject: [PATCH 44/53] Use n_frames and _seek_check

---
 Tests/test_file_ani.py    | 8 ++++----
 src/PIL/AniImagePlugin.py | 7 +++----
 2 files changed, 7 insertions(+), 8 deletions(-)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index 807ef0b6356..bcb9791dcdb 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -10,7 +10,7 @@
 def test_aero_busy() -> None:
     with Image.open("Tests/images/ani/aero_busy.ani") as im:
         assert im.size == (64, 64)
-        assert im.info["frames"] == 18
+        assert im.n_frames == 18
 
         with Image.open("Tests/images/ani/aero_busy_0.png") as png:
             assert png.tobytes() == im.tobytes()
@@ -29,7 +29,7 @@ def test_aero_busy() -> None:
 def test_posy_busy() -> None:
     with Image.open("Tests/images/ani/posy_busy.ani") as im:
         assert im.size == (96, 96)
-        assert im.info["frames"] == 77
+        assert im.n_frames == 77
 
         with Image.open("Tests/images/ani/posy_busy_0.png") as png:
             assert png.tobytes() == im.tobytes()
@@ -45,7 +45,7 @@ def test_posy_busy() -> None:
 def test_stopwtch() -> None:
     with Image.open("Tests/images/ani/stopwtch.ani") as im:
         assert im.size == (32, 32)
-        assert im.info["frames"] == 8
+        assert im.n_frames == 8
 
         assert im.info["seq"][0] == 0
         assert im.info["seq"][2] == 0
@@ -132,7 +132,7 @@ def test_save() -> None:
         )
 
         with Image.open(output, formats=["ANI"]) as im:
-            assert im.info["frames"] == 6
+            assert im.n_frames == 6
             assert im.info["seq"] == [0, 2, 4, 1, 3, 5, 0, 1, 0, 1]
             assert im.info["rate"] == [1, 2, 3, 1, 2, 3, 1, 2, 3, 4]
             assert im.size == (32, 32)
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 5d78d30244e..cf0ac2a3961 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -407,7 +407,7 @@ def _open(self) -> None:
         self.info["rate"] = self.ani.rate
 
         assert self.ani.anih is not None
-        self.info["frames"] = self.ani.anih["nFrames"]
+        self.n_frames = self.ani.anih["nFrames"]
 
         self.frame = 0
         self.seek(0)
@@ -434,9 +434,8 @@ def load(self) -> Image.core.PixelAccess | None:
         return Image.Image.load(self)
 
     def seek(self, frame: int) -> None:
-        if frame > self.info["frames"] - 1 or frame < 0:
-            msg = "Frame index out of animation bounds"
-            raise EOFError(msg)
+        if not self._seek_check(frame):
+            return
 
         self.frame = frame
 

From 60c89cb9e944dc0047c91dfcf303fa81654fa646 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 10 May 2025 19:53:24 +1000
Subject: [PATCH 45/53] Removed alpha property

---
 src/PIL/AniImagePlugin.py | 2 --
 src/PIL/BmpImagePlugin.py | 2 +-
 src/PIL/CurImagePlugin.py | 2 --
 3 files changed, 1 insertion(+), 5 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index cf0ac2a3961..06e21137398 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -69,8 +69,6 @@ def _save_frame(im: Image.Image, fp: BytesIO, info: dict[str, Any]) -> None:
                     image_io,
                     [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
                 )
-            else:
-                frame.alpha = True
 
             frame.save(image_io, "dib")
         else:
diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py
index 345cc24ab12..f763d2736b9 100644
--- a/src/PIL/BmpImagePlugin.py
+++ b/src/PIL/BmpImagePlugin.py
@@ -244,7 +244,7 @@ def _bitmap(self, header: int = 0, offset: int = 0) -> None:
                 msg = "Unsupported BMP bitfields layout"
                 raise OSError(msg)
         elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
-            if file_info["bits"] == 32 and (USE_RAW_ALPHA or hasattr(self, "alpha")):
+            if file_info["bits"] == 32 and USE_RAW_ALPHA:
                 raw_mode, self._mode = "BGRA", "RGBA"
         elif file_info["compression"] in (
             self.COMPRESSIONS["RLE8"],
diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 1a9ddf5ea73..91d561747b8 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -87,8 +87,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
                     image_io,
                     [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
                 )
-            else:
-                frame.alpha = True
 
             frame.save(image_io, "dib")
         else:

From 879f4b7e54263c21abc441a2f78a96cce8c7d802 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Mon, 28 Jul 2025 20:57:12 +1000
Subject: [PATCH 46/53] Moved hotspots into separate list to avoid conflicting
 header types

---
 src/PIL/CurImagePlugin.py | 67 ++++++++++++++++++---------------------
 1 file changed, 31 insertions(+), 36 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index 91d561747b8..39eb307a167 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -18,7 +18,8 @@
 from __future__ import annotations
 
 from io import BytesIO
-from typing import IO, NamedTuple
+from math import ceil, log
+from typing import IO
 
 from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile
 from ._binary import i16le as i16
@@ -110,20 +111,6 @@ def _accept(prefix: bytes) -> bool:
     return prefix.startswith(_MAGIC)
 
 
-class IconHeader(NamedTuple):
-    width: int
-    height: int
-    nb_color: int
-    reserved: int
-    bpp: int
-    x_hotspot: int
-    y_hotspot: int
-    size: int
-    offset: int
-    dim: tuple[int, int]
-    square: int
-
-
 ##
 # Image plugin for Windows Cursor files.
 class CurFile(IcoImagePlugin.IcoFile):
@@ -139,7 +126,7 @@ def __init__(self, buf: IO[bytes]) -> None:
             raise SyntaxError(msg)
 
         self.buf = buf
-        self.entry = []
+        entry = []
 
         # Number of items in file
         self.nb_items = i16(s, 4)
@@ -151,9 +138,12 @@ def __init__(self, buf: IO[bytes]) -> None:
             # See Wikipedia
             width = s[0] or 256
             height = s[1] or 256
+            square = width * height
 
             size = i32(s, 8)
-            square = width * height
+
+            # No. of colors in image (0 if >=8bpp)
+            nb_color = s[2]
 
             # TODO: This needs further investigation. Cursor files do not really
             # specify their bpp like ICO's as those bits are used for the y_hotspot.
@@ -167,26 +157,31 @@ def __init__(self, buf: IO[bytes]) -> None:
             else:
                 bpp = bpp_without_and
 
-            icon_header = IconHeader(
-                width=width,
-                height=height,
-                nb_color=s[2],  # No. of colors in image (0 if >=8bpp)
-                reserved=s[3],
-                bpp=bpp,
-                x_hotspot=i16(s, 4),
-                y_hotspot=i16(s, 6),
-                size=size,
-                offset=i32(s, 12),
-                dim=(width, height),
-                square=square,
+            entry.append(
+                (
+                    square,
+                    IcoImagePlugin.IconHeader(
+                        width=width,
+                        height=height,
+                        nb_color=nb_color,
+                        reserved=s[3],
+                        planes=0,
+                        bpp=bpp,
+                        size=size,
+                        offset=i32(s, 12),
+                        dim=(width, height),
+                        square=square,
+                        color_depth=bpp
+                        or (nb_color != 0 and ceil(log(nb_color, 2)))
+                        or 256,
+                    ),
+                    (i16(s, 4), i16(s, 6)),  # x, y
+                )
             )
 
-            self.entry.append(icon_header)
-
-        self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
-
-    def hotspots(self) -> list[tuple[int, int]]:
-        return [(h.x_hotspot, h.y_hotspot) for h in self.entry]
+        entry = sorted(entry, key=lambda x: x[0], reverse=True)
+        self.entry = [x[1] for x in entry]
+        self.hotspots = [x[2] for x in entry]
 
 
 class CurImageFile(IcoImagePlugin.IcoImageFile):
@@ -218,7 +213,7 @@ class CurImageFile(IcoImagePlugin.IcoImageFile):
     def _open(self) -> None:
         self.ico = CurFile(self.fp)
         self.info["sizes"] = self.ico.sizes()
-        self.info["hotspots"] = self.ico.hotspots()
+        self.info["hotspots"] = self.ico.hotspots
         if len(self.ico.entry) > 0:
             self.size = self.ico.entry[0].dim
         else:

From f5d866fce0e10251e1164688c5d593fb6c58edb7 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Mon, 28 Jul 2025 22:10:00 +1000
Subject: [PATCH 47/53] Use assert_image_equal and assert_image_equal_tofile

---
 Tests/test_file_ani.py | 35 ++++++++++++-----------------------
 Tests/test_file_cur.py | 20 +++++++++++---------
 2 files changed, 23 insertions(+), 32 deletions(-)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index bcb9791dcdb..25e8384ccef 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -6,18 +6,18 @@
 
 from PIL import Image
 
+from .helper import assert_image_equal_tofile
+
 
 def test_aero_busy() -> None:
     with Image.open("Tests/images/ani/aero_busy.ani") as im:
         assert im.size == (64, 64)
         assert im.n_frames == 18
 
-        with Image.open("Tests/images/ani/aero_busy_0.png") as png:
-            assert png.tobytes() == im.tobytes()
+        assert_image_equal_tofile(im, "Tests/images/ani/aero_busy_0.png")
 
         im.seek(8)
-        with Image.open("Tests/images/ani/aero_busy_8.png") as png:
-            assert png.tobytes() == im.tobytes()
+        assert_image_equal_tofile(im, "Tests/images/ani/aero_busy_8.png")
 
         with pytest.raises(EOFError):
             im.seek(-1)
@@ -31,44 +31,33 @@ def test_posy_busy() -> None:
         assert im.size == (96, 96)
         assert im.n_frames == 77
 
-        with Image.open("Tests/images/ani/posy_busy_0.png") as png:
-            assert png.tobytes() == im.tobytes()
+        assert_image_equal_tofile(im, "Tests/images/ani/posy_busy_0.png")
 
         im.seek(24)
-        with Image.open("Tests/images/ani/posy_busy_24.png") as png:
-            assert png.tobytes() == im.tobytes()
+        assert_image_equal_tofile(im, "Tests/images/ani/posy_busy_24.png")
 
         with pytest.raises(EOFError):
             im.seek(77)
 
 
-def test_stopwtch() -> None:
+def test_seq_rate() -> None:
     with Image.open("Tests/images/ani/stopwtch.ani") as im:
         assert im.size == (32, 32)
         assert im.n_frames == 8
 
-        assert im.info["seq"][0] == 0
-        assert im.info["seq"][2] == 0
-
-        for i, r in enumerate(im.info["rate"]):
-            if i == 1 or i == 2:
-                assert r == 16
-            else:
-                assert r == 8
+        assert im.info["seq"][:3] == [0, 1, 0]
+        assert im.info["rate"] == [8, 16, 16] + [8] * 42
 
-        with Image.open("Tests/images/ani/stopwtch_0.png") as png:
-            assert png.tobytes() == im.tobytes()
+        assert_image_equal_tofile(im, "Tests/images/ani/stopwtch_0.png")
 
         im.seek(5)
-        with Image.open("Tests/images/ani/stopwtch_5.png") as png:
-            assert png.tobytes() == im.tobytes()
+        assert_image_equal_tofile(im, "Tests/images/ani/stopwtch_5.png")
 
         with pytest.raises(EOFError):
             im.seek(8)
 
 
 def test_save() -> None:
-    directory_path = "Tests/images/ani/"
     filenames = [
         "aero_busy_0.png",
         "aero_busy_8.png",
@@ -78,7 +67,7 @@ def test_save() -> None:
         "stopwtch_5.png",
     ]
 
-    images = [Image.open(directory_path + filename) for filename in filenames]
+    images = [Image.open("Tests/images/ani/" + filename) for filename in filenames]
 
     with BytesIO() as output:
         images[0].save(
diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index 9246b1101d0..f27de97c247 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -6,13 +6,16 @@
 
 from PIL import CurImagePlugin, Image
 
+from .helper import assert_image_equal
 
-def test_deerstalker() -> None:
+
+def test_sanity() -> None:
     with Image.open("Tests/images/cur/deerstalker.cur") as im:
         assert im.size == (32, 32)
         assert im.info["hotspots"] == [(0, 0)]
         assert isinstance(im, CurImagePlugin.CurImageFile)
-        # Check some pixel colors to ensure image is loaded properly
+
+        # Check pixel colors to ensure image is loaded properly
         assert im.getpixel((10, 1)) == (0, 0, 0, 0)
         assert im.getpixel((11, 1)) == (253, 254, 254, 1)
         assert im.getpixel((16, 16)) == (84, 87, 86, 255)
@@ -23,7 +26,8 @@ def test_posy_link() -> None:
         assert im.size == (128, 128)
         assert im.info["sizes"] == {(128, 128), (96, 96), (64, 64), (48, 48), (32, 32)}
         assert im.info["hotspots"] == [(25, 7), (18, 5), (12, 3), (9, 2), (5, 1)]
-        # check some pixel colors
+
+        # check pixel colors
         assert im.getpixel((0, 0)) == (0, 0, 0, 0)
         assert im.getpixel((20, 20)) == (0, 0, 0, 255)
         assert im.getpixel((40, 40)) == (255, 255, 255, 255)
@@ -79,9 +83,8 @@ def test_save_win98_arrow() -> None:
                 hotspots=[(10, 10)],
                 bitmap_format="bmp",
             )
-
             with Image.open(output) as reloaded:
-                assert im.tobytes() == reloaded.tobytes()
+                assert_image_equal(im, reloaded)
 
         with BytesIO() as output:
             im.save(output, format="CUR")
@@ -120,11 +123,10 @@ def test_save_posy_link() -> None:
             # make sure saved output is readable
             # and sizes/hotspots are correct
             with Image.open(output, formats=["CUR"]) as reloaded:
-                assert (48, 48) == reloaded.size
-                assert set(sizes[3:]) == reloaded.info["sizes"]
+                assert reloaded.size == (48, 48)
+                assert reloaded.info["sizes"] == set(sizes[3:])
 
-            # make sure error is thrown when size and hotspot len's
-            # don't match
+            # check error is thrown when size and hotspot length don't match
             with pytest.raises(ValueError):
                 im.save(
                     output,

From fe21dfa881244510284690ec6a6c928a334e2c5a Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Wed, 3 Sep 2025 20:38:34 +1000
Subject: [PATCH 48/53] Simplified code

---
 src/PIL/AniImagePlugin.py | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 06e21137398..0fdcb1d40e1 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -107,13 +107,10 @@ def _write_single_frame(im: Image.Image, fp: IO[bytes]) -> None:
     fp.write(icon_data)
     fram_end = fp.tell()
 
-    fp.seek(icon_offset - 4)
-    icon_size = fram_end - icon_offset
-    fp.write(o32(icon_size))
-
-    fp.seek(list_offset - 4)
-    list_size = fram_end - list_offset
-    fp.write(o32(list_size))
+    for offset in (icon_offset, list_offset):
+        fp.seek(offset - 4)
+        size = fram_end - offset
+        fp.write(o32(size))
 
     fp.seek(fram_end)
 

From ff1e565e34bc12233fb8399b8e12709ca904b14c Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Wed, 3 Sep 2025 20:44:46 +1000
Subject: [PATCH 49/53] Removed unused method

---
 src/PIL/AniImagePlugin.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 0fdcb1d40e1..e9fb9688b69 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -365,9 +365,6 @@ def frame(self, frame: int) -> CurImagePlugin.CurImageFile:
     def sizes(self) -> list[int]:
         return [data["size"] for data in self.image_data]
 
-    def hotspots(self) -> None:
-        pass
-
 
 class AniImageFile(ImageFile.ImageFile):
     """

From bdf1f31a2fbdead41ce8a5a4927c40b902a8b7bc Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Mon, 22 Dec 2025 14:51:30 +1100
Subject: [PATCH 50/53] Raise error earlier

---
 src/PIL/CurImagePlugin.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py
index ccb6cfab0ca..529fae0a282 100644
--- a/src/PIL/CurImagePlugin.py
+++ b/src/PIL/CurImagePlugin.py
@@ -213,13 +213,13 @@ class CurImageFile(IcoImagePlugin.IcoImageFile):
     def _open(self) -> None:
         assert self.fp is not None
         self.ico = CurFile(self.fp)
-        self.info["sizes"] = self.ico.sizes()
-        self.info["hotspots"] = self.ico.hotspots
-        if len(self.ico.entry) > 0:
-            self.size = self.ico.entry[0].dim
-        else:
+        if not self.ico.entry:
             msg = "No cursors were found"
             raise TypeError(msg)
+
+        self.info["sizes"] = self.ico.sizes()
+        self.info["hotspots"] = self.ico.hotspots
+        self._size = self.ico.entry[0].dim
         self.load()
 
 

From 83d6815a89dd086cbe12f3641acb89169ed37bae Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Mon, 22 Dec 2025 15:03:37 +1100
Subject: [PATCH 51/53] Added tell()

---
 Tests/test_file_ani.py    | 1 +
 Tests/test_file_cur.py    | 6 ++++++
 src/PIL/AniImagePlugin.py | 9 ++++++---
 3 files changed, 13 insertions(+), 3 deletions(-)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index 25e8384ccef..b76258ef1ce 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -17,6 +17,7 @@ def test_aero_busy() -> None:
         assert_image_equal_tofile(im, "Tests/images/ani/aero_busy_0.png")
 
         im.seek(8)
+        assert im.tell() == 8
         assert_image_equal_tofile(im, "Tests/images/ani/aero_busy_8.png")
 
         with pytest.raises(EOFError):
diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index 086d4a11942..a100f1a1b9b 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -38,6 +38,12 @@ def test_largest_cursor() -> None:
         + o16(0)  # planes
         + o16(1)  # bits
     )
+
+    fp = BytesIO()
+    im = Image.new("1", (8, 8))
+    im.save(fp, "PNG")
+    data += fp.getvalue()
+
     with Image.open(BytesIO(data)) as im:
         assert im.size == (8, 8)
 
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index e9fb9688b69..086d7fbb170 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -401,7 +401,7 @@ def _open(self) -> None:
         assert self.ani.anih is not None
         self.n_frames = self.ani.anih["nFrames"]
 
-        self.frame = 0
+        self.__frame = 0
         self.seek(0)
         self.load()
         self.size = self.im.size
@@ -418,7 +418,7 @@ def size(self, value: tuple[int, int]) -> None:
         self._size = value
 
     def load(self) -> Image.core.PixelAccess | None:
-        im = self.ani.frame(self.frame)
+        im = self.ani.frame(self.__frame)
         self.info["sizes"] = im.info["sizes"]
         self.info["hotspots"] = im.info["hotspots"]
         self.im = im.im
@@ -429,7 +429,10 @@ def seek(self, frame: int) -> None:
         if not self._seek_check(frame):
             return
 
-        self.frame = frame
+        self.__frame = frame
+
+    def tell(self) -> int:
+        return self.__frame
 
 
 Image.register_open(AniImageFile.format, AniImageFile, _accept)

From e16a47be906c470e678308f08483ef5e031f0577 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Wed, 11 Feb 2026 13:30:11 +1100
Subject: [PATCH 52/53] Added new plugin to Image._EXTENSION_PLUGIN

---
 src/PIL/Image.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/PIL/Image.py b/src/PIL/Image.py
index cc431a86a5d..b408a1ac6ae 100644
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -341,6 +341,7 @@ def getmodebands(mode: str) -> int:
     ".png": "PngImagePlugin",
     ".apng": "PngImagePlugin",
     # Less common formats (init)
+    ".ani": "AniImagePlugin",
     ".avif": "AvifImagePlugin",
     ".avifs": "AvifImagePlugin",
     ".blp": "BlpImagePlugin",

From 24ed005fb3e7ebce4c278e36017c8b9e3605e197 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Thu, 12 Feb 2026 21:32:35 +1100
Subject: [PATCH 53/53] Updated type hints

---
 Tests/test_file_ani.py    | 6 +++++-
 Tests/test_file_cur.py    | 1 +
 src/PIL/AniImagePlugin.py | 3 ++-
 3 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py
index b76258ef1ce..ab0c96ed6b4 100644
--- a/Tests/test_file_ani.py
+++ b/Tests/test_file_ani.py
@@ -4,13 +4,14 @@
 
 import pytest
 
-from PIL import Image
+from PIL import AniImagePlugin, Image
 
 from .helper import assert_image_equal_tofile
 
 
 def test_aero_busy() -> None:
     with Image.open("Tests/images/ani/aero_busy.ani") as im:
+        assert isinstance(im, AniImagePlugin.AniImageFile)
         assert im.size == (64, 64)
         assert im.n_frames == 18
 
@@ -29,6 +30,7 @@ def test_aero_busy() -> None:
 
 def test_posy_busy() -> None:
     with Image.open("Tests/images/ani/posy_busy.ani") as im:
+        assert isinstance(im, AniImagePlugin.AniImageFile)
         assert im.size == (96, 96)
         assert im.n_frames == 77
 
@@ -43,6 +45,7 @@ def test_posy_busy() -> None:
 
 def test_seq_rate() -> None:
     with Image.open("Tests/images/ani/stopwtch.ani") as im:
+        assert isinstance(im, AniImagePlugin.AniImageFile)
         assert im.size == (32, 32)
         assert im.n_frames == 8
 
@@ -122,6 +125,7 @@ def test_save() -> None:
         )
 
         with Image.open(output, formats=["ANI"]) as im:
+            assert isinstance(im, AniImagePlugin.AniImageFile)
             assert im.n_frames == 6
             assert im.info["seq"] == [0, 2, 4, 1, 3, 5, 0, 1, 0, 1]
             assert im.info["rate"] == [1, 2, 3, 1, 2, 3, 1, 2, 3, 4]
diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py
index a100f1a1b9b..089e094b844 100644
--- a/Tests/test_file_cur.py
+++ b/Tests/test_file_cur.py
@@ -59,6 +59,7 @@ def test_posy_link() -> None:
         assert im.getpixel((20, 20)) == (0, 0, 0, 255)
         assert im.getpixel((40, 40)) == (255, 255, 255, 255)
 
+        assert isinstance(im, CurImagePlugin.CurImageFile)
         im.size = (32, 32)
         im.load()
         assert im.getpixel((0, 0)) == (0, 0, 0, 0)
diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py
index 086d7fbb170..a005191b35e 100644
--- a/src/PIL/AniImagePlugin.py
+++ b/src/PIL/AniImagePlugin.py
@@ -283,7 +283,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
 
 
 class AniFile:
-    def __init__(self, fp: BytesIO) -> None:
+    def __init__(self, fp: IO[bytes]) -> None:
         if not _accept(fp.read(4)):
             SyntaxError("Not an ANI file")
 
@@ -394,6 +394,7 @@ class AniImageFile(ImageFile.ImageFile):
     format_description = "Windows Animated Cursor"
 
     def _open(self) -> None:
+        assert self.fp is not None
         self.ani = AniFile(self.fp)
         self.info["seq"] = self.ani.seq
         self.info["rate"] = self.ani.rate
