diff --git a/Tests/images/ani/aero_busy.ani b/Tests/images/ani/aero_busy.ani new file mode 100644 index 00000000000..46e1e36d22c Binary files /dev/null and b/Tests/images/ani/aero_busy.ani differ diff --git a/Tests/images/ani/aero_busy_0.png b/Tests/images/ani/aero_busy_0.png new file mode 100644 index 00000000000..d084aec9d40 Binary files /dev/null and b/Tests/images/ani/aero_busy_0.png differ diff --git a/Tests/images/ani/aero_busy_8.png b/Tests/images/ani/aero_busy_8.png new file mode 100644 index 00000000000..9d6e56ac1b0 Binary files /dev/null and b/Tests/images/ani/aero_busy_8.png differ diff --git a/Tests/images/ani/posy_busy.ani b/Tests/images/ani/posy_busy.ani new file mode 100644 index 00000000000..8ac0abef545 Binary files /dev/null and b/Tests/images/ani/posy_busy.ani differ diff --git a/Tests/images/ani/posy_busy_0.png b/Tests/images/ani/posy_busy_0.png new file mode 100644 index 00000000000..6dad8480512 Binary files /dev/null and b/Tests/images/ani/posy_busy_0.png differ diff --git a/Tests/images/ani/posy_busy_24.png b/Tests/images/ani/posy_busy_24.png new file mode 100644 index 00000000000..684482835db Binary files /dev/null and b/Tests/images/ani/posy_busy_24.png differ diff --git a/Tests/images/ani/stopwtch.ani b/Tests/images/ani/stopwtch.ani new file mode 100644 index 00000000000..1b3461ef599 Binary files /dev/null and b/Tests/images/ani/stopwtch.ani differ diff --git a/Tests/images/ani/stopwtch_0.png b/Tests/images/ani/stopwtch_0.png new file mode 100644 index 00000000000..3c6471229bb Binary files /dev/null and b/Tests/images/ani/stopwtch_0.png differ diff --git a/Tests/images/ani/stopwtch_5.png b/Tests/images/ani/stopwtch_5.png new file mode 100644 index 00000000000..623d9283e00 Binary files /dev/null and b/Tests/images/ani/stopwtch_5.png differ diff --git a/Tests/images/cur/aero_arrow.cur b/Tests/images/cur/aero_arrow.cur new file mode 100644 index 00000000000..7d58eec1285 Binary files /dev/null and b/Tests/images/cur/aero_arrow.cur differ 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 00000000000..e6230080255 Binary files /dev/null and b/Tests/images/cur/posy_link.cur differ diff --git a/Tests/images/cur/posy_link.png b/Tests/images/cur/posy_link.png new file mode 100644 index 00000000000..65199b6d2d4 Binary files /dev/null and b/Tests/images/cur/posy_link.png differ diff --git a/Tests/images/cur/stopwtch.cur b/Tests/images/cur/stopwtch.cur new file mode 100644 index 00000000000..858827ddbcb Binary files /dev/null and b/Tests/images/cur/stopwtch.cur differ diff --git a/Tests/images/cur/win98_arrow.cur b/Tests/images/cur/win98_arrow.cur new file mode 100644 index 00000000000..68c65b20ee7 Binary files /dev/null and b/Tests/images/cur/win98_arrow.cur differ diff --git a/Tests/images/cur/win98_arrow.png b/Tests/images/cur/win98_arrow.png new file mode 100644 index 00000000000..f8bf4b1a76a Binary files /dev/null and b/Tests/images/cur/win98_arrow.png differ diff --git a/Tests/test_file_ani.py b/Tests/test_file_ani.py new file mode 100644 index 00000000000..dc538b13fcb --- /dev/null +++ b/Tests/test_file_ani.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +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/Tests/test_file_cur.py b/Tests/test_file_cur.py index dbf1b866d7f..586e36c539b 100644 --- a/Tests/test_file_cur.py +++ b/Tests/test_file_cur.py @@ -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(): + 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() -> 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(): + 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/AniImagePlugin.py b/src/PIL/AniImagePlugin.py new file mode 100644 index 00000000000..e66d5ec2384 --- /dev/null +++ b/src/PIL/AniImagePlugin.py @@ -0,0 +1,441 @@ +from __future__ import annotations + +import struct +from io import BytesIO + +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(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 + 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, [("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: 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] + 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, 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): + 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: 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): + 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: 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: + 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): + 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) + + 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._min_frame = 0 + self.seek(0) + self.size = self.im.size + + @property + def size(self): + return self._size + + @size.setter + def size(self, value): + 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): + 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.info["frames"] - 1 or frame < 0: + msg = "Frame index out of animation bounds" + raise EOFError(msg) + + self.frame = frame + self.load() + + +Image.register_open(AniImageFile.format, AniImageFile, _accept) +Image.register_extension(AniImageFile.format, ".ani") +Image.register_save(AniImageFile.format, _save) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index d60ea591ab9..14410f7eb2d 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -242,8 +242,11 @@ 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 header == 22: # 32-bit .cur offset - 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"] in ( self.COMPRESSIONS["RLE8"], self.COMPRESSIONS["RLE4"], diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py index b817dbc87b8..1f2c326dd3f 100644 --- a/src/PIL/CurImagePlugin.py +++ b/src/PIL/CurImagePlugin.py @@ -17,59 +17,212 @@ # from __future__ import annotations -from . import BmpImagePlugin, Image, ImageFile +from io import BytesIO + +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: BytesIO, filename: str): + 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, [("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) ## # Image plugin for Windows Cursor files. +class CurFile(IcoImagePlugin.IcoFile): + def __init__(self, buf: BytesIO()): + """ + Parse image from file-like object containing cur file data + """ + + # check if CUR + s = buf.read(6) + if not _accept(s): + msg = "not a CUR file" + raise SyntaxError(msg) + + 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) + + 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 + + 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. + BITMAP_INFO_HEADER_SIZE = 40 + 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) + + 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): + """ + 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. + """ -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) - 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.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") diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 09546fe6333..b47e7e36517 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -25,6 +25,7 @@ _plugins = [ + "AniImagePlugin", "BlpImagePlugin", "BmpImagePlugin", "BufrStubImagePlugin",