diff --git a/Tests/images/ani/aero_busy.ani b/Tests/images/ani/aero_busy.ani new file mode 100644 index 000000000..46e1e36d2 Binary files /dev/null and b/Tests/images/ani/aero_busy.ani differ diff --git a/Tests/images/ani/posy_busy.ani b/Tests/images/ani/posy_busy.ani new file mode 100644 index 000000000..8ac0abef5 Binary files /dev/null and b/Tests/images/ani/posy_busy.ani differ diff --git a/Tests/images/ani/stopwtch.ani b/Tests/images/ani/stopwtch.ani new file mode 100644 index 000000000..1b3461ef5 Binary files /dev/null and b/Tests/images/ani/stopwtch.ani differ diff --git a/src/PIL/AniImagePlugin.py b/src/PIL/AniImagePlugin.py new file mode 100644 index 000000000..af3af6368 --- /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 e65b155b2..ab48f74e8 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -23,6 +23,7 @@ del _version _plugins = [ + "AniImagePlugin", "BlpImagePlugin", "BmpImagePlugin", "BufrStubImagePlugin",