This commit is contained in:
Jonathan Woolf 2025-05-23 16:18:04 +00:00 committed by GitHub
commit 667e6ed303
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 920 additions and 40 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6 B

After

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

171
Tests/test_file_ani.py Normal file
View File

@ -0,0 +1,171 @@
from __future__ import annotations
from io import BytesIO
import pytest
from PIL import Image
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()
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() -> None:
with Image.open("Tests/images/ani/posy_busy.ani") as im:
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()
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() -> 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
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() -> None:
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.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)
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)],
)

View File

@ -1,15 +1,16 @@
from __future__ import annotations
from io import BytesIO
import pytest
from PIL import CurImagePlugin, Image
TEST_FILE = "Tests/images/deerstalker.cur"
def test_sanity() -> None:
with Image.open(TEST_FILE) as im:
def test_deerstalker() -> 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
assert im.getpixel((10, 1)) == (0, 0, 0, 0)
@ -17,16 +18,118 @@ def test_sanity() -> None:
assert im.getpixel((16, 16)) == (84, 87, 86, 255)
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)}
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() -> None:
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() -> None:
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() -> None:
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 = CurImagePlugin.CurImageFile("Tests/images/cur/deerstalker.cur")
cur.fp.close()
with open(no_cursors_file, "rb") as cur.fp:
with pytest.raises(TypeError):
cur._open()
def test_save_win98_arrow() -> None:
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 reloaded:
assert im.tobytes() == reloaded.tobytes()
with BytesIO() as output:
im.save(output, format="CUR")
# check default save params
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() -> None:
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 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 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
with pytest.raises(ValueError):
im.save(
output,
sizes=sizes[2:],
hotspots=hotspots[3:],
format="CUR",
bitmap_format="bmp",
)

445
src/PIL/AniImagePlugin.py Normal file
View File

@ -0,0 +1,445 @@
from __future__ import annotations
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
from PIL._binary import o8
from PIL._binary import o16le as o16
from PIL._binary import o32le as o32
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"RIFF")
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(
"sizes",
[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
)
h = info.get("hotspots", [(0, 0) for _ in range(len(s))])
if len(h) != len(s):
msg = "Number of hotspots must be equal to number of cursor sizes"
raise ValueError(msg)
# sort and remove duplicate sizes
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(hotspot)
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:
if bits != 32:
and_mask = Image.new("1", size)
ImageFile._save(
and_mask,
image_io,
[ImageFile._Tile("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)
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: 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)
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, 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: IO[bytes]) -> None:
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]
frames.extend(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, 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):
msg = "Sequence index out of animation frame bounds"
raise ValueError(msg)
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):
msg = "Length of rate must match length of sequence"
raise ValueError(msg)
else:
if len(rate) != len(frames):
msg = "Length of rate must match number of frames"
raise ValueError(msg)
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)
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: IO[bytes], filename: str | bytes) -> None:
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):
msg = "'inam' argument must be either a string or bytes"
raise TypeError(msg)
if isinstance(iart, str):
iart = iart.encode()
if not isinstance(iart, bytes):
msg = "'iart' argument must be either a string or bytes"
raise TypeError(msg)
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: IO[bytes], filename: str | bytes) -> None:
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)
else:
_write_single_frame(im, fp)
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, fp: BytesIO) -> None:
if not _accept(fp.read(4)):
SyntaxError("Not an ANI file")
self.image_data = []
self.buf = fp
self.rate = None
self.seq = None
self.anih = None
size, fformat = struct.unpack("<I4s", fp.read(8))
self.riff = {"size": size, "fformat": fformat}
chunkOffset = fp.tell()
while chunkOffset < self.riff["size"]:
fp.seek(chunkOffset)
chunk, size = struct.unpack("<4sI", fp.read(8))
chunkOffset = chunkOffset + size + 8
if chunk == b"anih":
s = fp.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 = fp.read(size)
self.seq = [i32(s, i * 4) for i in range(size // 4)]
if chunk == b"rate":
s = fp.read(size)
self.rate = [i32(s, i * 4) for i in range(size // 4)]
if chunk == b"LIST":
listtype = struct.unpack("<4s", fp.read(4))[0]
if listtype != b"fram":
continue
listOffset = 0
while listOffset < size - 8:
_, lSize = struct.unpack("<4sI", fp.read(8))
self.image_data.append({"offset": fp.tell(), "size": lSize})
fp.read(lSize)
listOffset = listOffset + lSize + 8
if self.anih is None:
msg = "not an ANI file"
raise SyntaxError(msg)
if self.seq is None:
self.seq = list(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: int) -> CurImagePlugin.CurImageFile:
assert self.anih is not None
if frame > self.anih["nFrames"]:
msg = "Frame index out of animation bounds"
raise EOFError(msg)
offset, size = self.image_data[frame].values()
self.buf.seek(offset)
data = self.buf.read(size)
return CurImagePlugin.CurImageFile(BytesIO(data))
def sizes(self) -> list[int]:
return [data["size"] for data in self.image_data]
def hotspots(self) -> None:
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) -> 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.n_frames = self.ani.anih["nFrames"]
self.frame = 0
self.seek(0)
self.load()
self.size = self.im.size
@property
def size(self) -> tuple[int, int]:
return self._size
@size.setter
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) -> 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 not self._seek_check(frame):
return
self.frame = frame
Image.register_open(AniImageFile.format, AniImageFile, _accept)
Image.register_extension(AniImageFile.format, ".ani")
Image.register_save(AniImageFile.format, _save)

View File

@ -244,9 +244,7 @@ class BmpImageFile(ImageFile.ImageFile):
msg = "Unsupported BMP bitfields layout"
raise OSError(msg)
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
if file_info["bits"] == 32 and (
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
):
if file_info["bits"] == 32 and (USE_RAW_ALPHA or hasattr(self, "alpha")):
raw_mode, self._mode = "BGRA", "RGBA"
elif file_info["compression"] in (
self.COMPRESSIONS["RLE8"],

View File

@ -17,59 +17,221 @@
#
from __future__ import annotations
from . import BmpImagePlugin, Image, ImageFile
from io import BytesIO
from typing import IO, NamedTuple
from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile
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
#
# --------------------------------------------------------------------
_MAGIC = b"\x00\x00\x02\x00"
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(
"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))])
if len(h) != len(s):
msg = "Number of hotspots must be equal to number of cursor sizes"
raise ValueError(msg)
# sort and remove duplicate sizes
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(hotspot)
frames = []
width, height = im.size
for size in 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:
if bits != 32:
and_mask = Image.new("1", size)
ImageFile._save(
and_mask,
image_io,
[ImageFile._Tile("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)
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: bytes) -> bool:
return prefix.startswith(b"\0\0\2\0")
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: IO[bytes]) -> None:
"""
Parse image from file-like object containing cur file data
"""
class CurImageFile(BmpImagePlugin.BmpImageFile):
format = "CUR"
format_description = "Windows Cursor"
def _open(self) -> None:
offset = self.fp.tell()
# check magic
s = self.fp.read(6)
# check if CUR
s = buf.read(6)
if not _accept(s):
msg = "not a CUR file"
raise SyntaxError(msg)
# 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:
self.buf = buf
self.entry = []
# Number of items in file
self.nb_items = i16(s, 4)
# Get headers for each item
for _ in range(self.nb_items):
s = buf.read(16)
# See Wikipedia
width = s[0] or 256
height = s[1] or 256
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.
# 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 = ((size - BITMAP_INFO_HEADER_SIZE) * 8) // square
if bpp_without_and != 32:
bpp = ((size - BITMAP_INFO_HEADER_SIZE) * 8 - square) // square
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,
)
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]
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"
def _open(self) -> 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:
msg = "No cursors were found"
raise TypeError(msg)
# load as bitmap
self._bitmap(i32(m, 12) + offset)
# patch up the bitmap height
self._size = self.size[0], self.size[1] // 2
d, e, o, a = self.tile[0]
self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a)
self.load()
#
# --------------------------------------------------------------------
Image.register_open(CurImageFile.format, CurImageFile, _accept)
Image.register_save(CurImageFile.format, _save)
Image.register_extension(CurImageFile.format, ".cur")

View File

@ -25,6 +25,7 @@ del _version
_plugins = [
"AniImagePlugin",
"AvifImagePlugin",
"BlpImagePlugin",
"BmpImagePlugin",