Merge ccfc2524b8
into 7e4d8e2f55
BIN
Tests/images/ani/aero_busy.ani
Normal file
BIN
Tests/images/ani/aero_busy_0.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
Tests/images/ani/aero_busy_8.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
Tests/images/ani/posy_busy.ani
Normal file
BIN
Tests/images/ani/posy_busy_0.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
Tests/images/ani/posy_busy_24.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
Tests/images/ani/stopwtch.ani
Normal file
BIN
Tests/images/ani/stopwtch_0.png
Normal file
After Width: | Height: | Size: 344 B |
BIN
Tests/images/ani/stopwtch_5.png
Normal file
After Width: | Height: | Size: 331 B |
BIN
Tests/images/cur/aero_arrow.cur
Normal file
After Width: | Height: | Size: 133 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 6 B After Width: | Height: | Size: 6 B |
BIN
Tests/images/cur/posy_link.cur
Normal file
After Width: | Height: | Size: 133 KiB |
BIN
Tests/images/cur/posy_link.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
Tests/images/cur/stopwtch.cur
Normal file
After Width: | Height: | Size: 766 B |
BIN
Tests/images/cur/win98_arrow.cur
Normal file
After Width: | Height: | Size: 326 B |
BIN
Tests/images/cur/win98_arrow.png
Normal file
After Width: | Height: | Size: 199 B |
171
Tests/test_file_ani.py
Normal 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)],
|
||||
)
|
|
@ -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
|
@ -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)
|
|
@ -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"],
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -25,6 +25,7 @@ del _version
|
|||
|
||||
|
||||
_plugins = [
|
||||
"AniImagePlugin",
|
||||
"AvifImagePlugin",
|
||||
"BlpImagePlugin",
|
||||
"BmpImagePlugin",
|
||||
|
|