From e7ec91ee184672e450e342d50933c84479c06449 Mon Sep 17 00:00:00 2001 From: Quantum Date: Fri, 2 Oct 2020 23:37:34 -0400 Subject: [PATCH] Add Xcursor loading support --- win2xcur/parser/__init__.py | 3 +- win2xcur/parser/ani.py | 5 +- win2xcur/parser/xcursor.py | 98 +++++++++++++++++++++++++++++++++++++ win2xcur/utils.py | 20 ++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 win2xcur/parser/xcursor.py create mode 100644 win2xcur/utils.py diff --git a/win2xcur/parser/__init__.py b/win2xcur/parser/__init__.py index ec03937..9bb18e0 100644 --- a/win2xcur/parser/__init__.py +++ b/win2xcur/parser/__init__.py @@ -3,8 +3,9 @@ from typing import List, Type from win2xcur.parser.ani import ANIParser from win2xcur.parser.base import BaseParser from win2xcur.parser.cur import CURParser +from win2xcur.parser.xcursor import XCursorParser -PARSERS: List[Type[BaseParser]] = [CURParser, ANIParser] +PARSERS: List[Type[BaseParser]] = [CURParser, ANIParser, XCursorParser] def open_blob(blob: bytes) -> BaseParser: diff --git a/win2xcur/parser/ani.py b/win2xcur/parser/ani.py index 05badf7..43c4503 100644 --- a/win2xcur/parser/ani.py +++ b/win2xcur/parser/ani.py @@ -23,7 +23,10 @@ class ANIParser(BaseParser): signature: bytes size: int subtype: bytes - signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size]) + try: + signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size]) + except struct.error: + return False return signature == cls.SIGNATURE and size == len(blob) - 8 and subtype == cls.ANI_TYPE def __init__(self, blob: bytes) -> None: diff --git a/win2xcur/parser/xcursor.py b/win2xcur/parser/xcursor.py new file mode 100644 index 0000000..a6bc5af --- /dev/null +++ b/win2xcur/parser/xcursor.py @@ -0,0 +1,98 @@ +import struct +from collections import defaultdict +from typing import Any, Dict, Iterator, List, Tuple, cast + +from win2xcur.cursor import CursorFrame, CursorImage +from win2xcur.parser.base import BaseParser +from win2xcur.utils import image_from_pixels + + +class XCursorParser(BaseParser): + MAGIC = b'Xcur' + VERSION = 0x1_0000 + FILE_HEADER = struct.Struct('<4sIII') + TOC_CHUNK = struct.Struct(' bool: + return blob[:len(cls.MAGIC)] == cls.MAGIC + + def __init__(self, blob: bytes) -> None: + super().__init__(blob) + self.frames = self._parse() + + def _unpack(self, struct_cls: struct.Struct, offset: int) -> Tuple[Any, ...]: + return struct_cls.unpack(self.blob[offset:offset + struct_cls.size]) + + def _parse(self) -> List[CursorFrame]: + magic, header_size, version, toc_size = self._unpack(self.FILE_HEADER, 0) + assert magic == self.MAGIC + + if version != self.VERSION: + raise ValueError('Unsupported Xcursor version %r' % version) + + offset = self.FILE_HEADER.size + chunks: List[Tuple[int, int, int]] = [] + for i in range(toc_size): + chunk_type, chunk_subtype, position = self._unpack(self.TOC_CHUNK, offset) + chunks.append((chunk_type, chunk_subtype, position)) + offset += self.TOC_CHUNK.size + + images_by_size: Dict[int, List[Tuple[CursorImage, int]]] = defaultdict(list) + + for chunk_type, chunk_subtype, position in chunks: + if chunk_type != self.CHUNK_IMAGE: + continue + + size, actual_type, nominal_size, version, width, height, x_offset, y_offset, delay = \ + self._unpack(self.IMAGE_HEADER, position) + + if size != self.IMAGE_HEADER.size: + raise ValueError('Unexpected size: %r, expected %r' % (size, self.IMAGE_HEADER.size)) + + if actual_type != chunk_type: + raise ValueError('Unexpected chunk type: %r, expected %r' % (actual_type, chunk_type)) + + if nominal_size != chunk_subtype: + raise ValueError('Unexpected nominal size: %r, expected %r' % (nominal_size, chunk_subtype)) + + if width > 0x7FFF: + raise ValueError('Image width too large: %r' % width) + + if height > 0x7FFF: + raise ValueError('Image height too large: %r' % height) + + if x_offset > width: + raise ValueError('Hotspot x-coordinate too large: %r' % x_offset) + + if y_offset > height: + raise ValueError('Hotspot x-coordinate too large: %r' % y_offset) + + image_start = position + self.IMAGE_HEADER.size + image_size = width * height * 4 + blob = self.blob[image_start:image_start + image_size] + if len(blob) != image_size: + raise ValueError('Invalid image at %d: expected %d bytes, got %d bytes' % + (image_size, image_size, len(blob))) + + images_by_size[nominal_size].append( + (CursorImage(image_from_pixels(blob, width, height, 'ARGB', 'char'), (x_offset, y_offset)), delay) + ) + + if len(set(map(len, images_by_size.values()))) != 1: + raise ValueError('win2xcur does not support animations where each size has different number of frames') + + result = [] + for sequence in cast(Any, zip(*images_by_size.values())): + images: Tuple[CursorImage, ...] + delays: Tuple[int, ...] + images, delays = cast(Any, zip(*sequence)) + + if len(set(delays)) != 1: + raise ValueError('win2xcur does not support animations where each size has a different frame delay') + + result.append(CursorFrame(list(images), delays[0])) + + return result diff --git a/win2xcur/utils.py b/win2xcur/utils.py new file mode 100644 index 0000000..b40c437 --- /dev/null +++ b/win2xcur/utils.py @@ -0,0 +1,20 @@ +import ctypes + +from wand.api import library +from wand.image import Image +from wand.sequence import SingleImage + +MagickImportImagePixels = library['MagickImportImagePixels'] +MagickImportImagePixels.argtypes = ( + ctypes.c_void_p, ctypes.c_ssize_t, ctypes.c_ssize_t, ctypes.c_size_t, + ctypes.c_size_t, ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p +) +StorageType = ('undefined', 'char', 'double', 'float', + 'integer', 'long', 'quantum', 'short') + + +def image_from_pixels(blob: bytes, width: int, height: int, pixel_format: str, pixel_size: str) -> SingleImage: + image = Image(width=width, height=height) + MagickImportImagePixels(image.wand, 0, 0, width, height, pixel_format.encode('ascii'), + StorageType.index(pixel_size), blob) + return image.sequence[0]