├── .flake8 ├── .github └── workflows │ └── build.yml ├── .gitignore ├── README.md ├── codecov.yml ├── mypy.ini ├── requirements.txt ├── sample └── crosshair.cur ├── setup.py └── win2xcur ├── __init__.py ├── cursor.py ├── main ├── __init__.py ├── win2xcur.py └── x2wincur.py ├── parser ├── __init__.py ├── ani.py ├── base.py ├── cur.py └── xcursor.py ├── scale.py ├── shadow.py ├── utils.py └── writer ├── __init__.py ├── windows.py └── x11.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | application-import-names = win2xcur 4 | import-order-style = pycharm 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: [ 3.7, 3.8, 3.9, '3.10' ] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Cache wheels 20 | uses: actions/cache@v2 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements.txt') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip-${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 flake8-import-order mypy wheel coverage 30 | pip install -r requirements.txt 31 | sudo apt-get install dmz-cursor-theme 32 | - name: Lint with flake8 33 | run: flake8 . 34 | - name: Typecheck with mypy 35 | if: matrix.python-version != 3.7 36 | run: mypy . 37 | - name: Test packages 38 | run: python setup.py sdist bdist_wheel 39 | - name: Test wheel install 40 | run: pip install dist/*.whl 41 | - name: Test with sample/crosshair.cur 42 | run: | 43 | coverage run -m win2xcur.main.win2xcur sample/crosshair.cur -o /tmp 44 | ls -l /tmp/crosshair 45 | - name: Test with animated cursors 46 | run: | 47 | wget http://www.anicursor.com/waiting.zip 48 | mkdir ani output 49 | unzip waiting.zip -d ani 50 | coverage run -a -m win2xcur.main.win2xcur -s ani/*.ani -o output 51 | ls -l output/* 52 | - name: Test with dmz-cursor-theme 53 | run: | 54 | mkdir dmz-white 55 | coverage run -a -m win2xcur.main.x2wincur /usr/share/icons/DMZ-White/cursors/* -o dmz-white 56 | ls -l dmz-white/* 57 | - name: Generating coverage report 58 | run: coverage xml 59 | - uses: codecov/codecov-action@v1 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # PyCharm 141 | .idea 142 | 143 | # For testing 144 | input/ 145 | output/ 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `win2xcur` and `x2wincur` [![Build Status](https://img.shields.io/github/actions/workflow/status/quantum5/win2xcur/build.yml)](https://github.com/quantum5/win2xcur/actions) [![PyPI](https://img.shields.io/pypi/v/win2xcur.svg)](https://pypi.org/project/win2xcur/) [![PyPI - Format](https://img.shields.io/pypi/format/win2xcur.svg)](https://pypi.org/project/win2xcur/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/win2xcur.svg)](https://pypi.org/project/win2xcur/) 2 | 3 | `win2xcur` is a tool that converts cursors from Windows format (`*.cur`, 4 | `*.ani`) to Xcursor format. This allows Windows cursor themes to be used on 5 | Linux, for example. 6 | 7 | `win2xcur` is more than a simple image conversion tool. It preserves the cursor 8 | hotspot and animation delay, and has an optional mode to add shadows that 9 | replicates Windows's cursor shadow effect. 10 | 11 | `x2wincur` is a tool that does the opposite: it converts cursors in the Xcursor 12 | format to Windows format (`*.cur`, `*.ani`), allowing to use your favourite 13 | Linux cursor themes on Windows. 14 | 15 | ## Installation 16 | 17 | To install the latest stable version: 18 | 19 | pip install win2xcur 20 | 21 | To install from GitHub: 22 | 23 | pip install -e git+https://github.com/quantum5/win2xcur.git 24 | 25 | ## Usage: `win2xcur` 26 | 27 | For example, if you want to convert [the sample cursor](sample/crosshair.cur) 28 | to Linux format: 29 | 30 | mkdir output/ 31 | win2xcur sample/crosshair.cur -o output/ 32 | 33 | `-s` can be specified to enable shadows. 34 | Multiple cursors files can be specified on the command line. 35 | For example, to convert a directory of cursors with shadows enabled: 36 | 37 | win2xcur input/*.{ani,cur} -o output/ 38 | 39 | For more information, run `win2xcur --help`. 40 | 41 | ## Usage: `x2wincur` 42 | 43 | For example, if you want to convert DMZ-White to Windows: 44 | 45 | mkdir dmz-white/ 46 | x2wincur /usr/share/icons/DMZ-White/cursors/* -o dmz-white/ 47 | 48 | ## Troubleshooting 49 | 50 | `win2xcur` and `x2wincur` should work out of the box on most systems. If you 51 | are using unconventional distros (e.g. Alpine) and are getting errors related 52 | to `wand`, please see the [Wand documentation on installation][wand-install]. 53 | 54 | [wand-install]: https://docs.wand-py.org/en/0.6.7/guide/install.html 55 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | patch: off 5 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | strict = true 4 | plugins = numpy.typing.mypy_plugin 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | Wand 3 | -------------------------------------------------------------------------------- /sample/crosshair.cur: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantum5/win2xcur/8e71037f5f90f3cea82a74fe516ee637dea113fa/sample/crosshair.cur -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f: 6 | long_description = f.read() 7 | 8 | setup( 9 | name='win2xcur', 10 | version='0.1.2', 11 | packages=find_packages(), 12 | install_requires=['numpy', 'Wand'], 13 | 14 | entry_points={ 15 | 'console_scripts': [ 16 | 'win2xcur = win2xcur.main.win2xcur:main', 17 | 'x2wincur = win2xcur.main.x2wincur:main', 18 | ], 19 | }, 20 | 21 | author='quantum', 22 | author_email='quantum2048@gmail.com', 23 | url='https://github.com/quantum5/win2xcur', 24 | description='win2xcur is a tool to convert Windows .cur and .ani cursors to Xcursor format.', 25 | long_description=long_description, 26 | long_description_content_type='text/markdown', 27 | keywords='cur ani x11 windows win32 cursor xcursor', 28 | classifiers=[ 29 | 'Development Status :: 3 - Alpha', 30 | 'Environment :: Win32 (MS Windows)', 31 | 'Environment :: X11 Applications', 32 | 'Intended Audience :: End Users/Desktop', 33 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 34 | 'Operating System :: Microsoft :: Windows', 35 | 'Operating System :: POSIX :: Linux', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 3 :: Only', 38 | 'Programming Language :: Python :: 3.6', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Programming Language :: Python :: 3.9', 42 | 'Topic :: Desktop Environment', 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /win2xcur/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantum5/win2xcur/8e71037f5f90f3cea82a74fe516ee637dea113fa/win2xcur/__init__.py -------------------------------------------------------------------------------- /win2xcur/cursor.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, List, Tuple 2 | 3 | from wand.sequence import SingleImage 4 | 5 | 6 | class CursorImage: 7 | image: SingleImage 8 | hotspot: Tuple[int, int] 9 | nominal: int 10 | 11 | def __init__(self, image: SingleImage, hotspot: Tuple[int, int], nominal: int) -> None: 12 | self.image = image 13 | self.hotspot = hotspot 14 | self.nominal = nominal 15 | 16 | def __repr__(self) -> str: 17 | return f'CursorImage(image={self.image!r}, hotspot={self.hotspot!r}, nominal={self.nominal!r})' 18 | 19 | 20 | class CursorFrame: 21 | images: List[CursorImage] 22 | delay: int 23 | 24 | def __init__(self, images: List[CursorImage], delay: int = 0) -> None: 25 | self.images = images 26 | self.delay = delay 27 | 28 | def __getitem__(self, item: int) -> CursorImage: 29 | return self.images[item] 30 | 31 | def __len__(self) -> int: 32 | return len(self.images) 33 | 34 | def __iter__(self) -> Iterator[CursorImage]: 35 | return iter(self.images) 36 | 37 | def __repr__(self) -> str: 38 | return f'CursorFrame(images={self.images!r}, delay={self.delay!r})' 39 | -------------------------------------------------------------------------------- /win2xcur/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantum5/win2xcur/8e71037f5f90f3cea82a74fe516ee637dea113fa/win2xcur/main/__init__.py -------------------------------------------------------------------------------- /win2xcur/main/win2xcur.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | import traceback 5 | from multiprocessing import cpu_count 6 | from multiprocessing.pool import ThreadPool 7 | from threading import Lock 8 | from typing import BinaryIO 9 | 10 | from win2xcur import scale, shadow 11 | from win2xcur.parser import open_blob 12 | from win2xcur.writer import to_x11 13 | 14 | 15 | def main() -> None: 16 | parser = argparse.ArgumentParser(description='Converts Windows cursors to X11 cursors.') 17 | parser.add_argument('files', type=argparse.FileType('rb'), nargs='+', 18 | help='Windows cursor files to convert (*.cur, *.ani)') 19 | parser.add_argument('-o', '--output', '--output-dir', default=os.curdir, 20 | help='Directory to store converted cursor files.') 21 | parser.add_argument('-s', '--shadow', action='store_true', 22 | help="Whether to emulate Windows's shadow effect") 23 | parser.add_argument('-O', '--shadow-opacity', type=int, default=50, 24 | help='Opacity of the shadow (0 to 255)') 25 | parser.add_argument('-r', '--shadow-radius', type=float, default=0.1, 26 | help='Radius of shadow blur effect (as fraction of width)') 27 | parser.add_argument('-S', '--shadow-sigma', type=float, default=0.1, 28 | help='Sigma of shadow blur effect (as fraction of width)') 29 | parser.add_argument('-x', '--shadow-x', type=float, default=0.05, 30 | help='x-offset of shadow (as fraction of width)') 31 | parser.add_argument('-y', '--shadow-y', type=float, default=0.05, 32 | help='y-offset of shadow (as fraction of height)') 33 | parser.add_argument('-c', '--shadow-color', default='#000000', 34 | help='color of the shadow') 35 | parser.add_argument('--scale', default=None, type=float, 36 | help='Scale the cursor by the specified factor.') 37 | 38 | args = parser.parse_args() 39 | print_lock = Lock() 40 | 41 | def process(file: BinaryIO) -> None: 42 | name = file.name 43 | blob = file.read() 44 | try: 45 | cursor = open_blob(blob) 46 | except Exception: 47 | with print_lock: 48 | print(f'Error occurred while processing {name}:', file=sys.stderr) 49 | traceback.print_exc() 50 | else: 51 | if args.scale: 52 | scale.apply_to_frames(cursor.frames, scale=args.scale) 53 | if args.shadow: 54 | shadow.apply_to_frames(cursor.frames, color=args.shadow_color, radius=args.shadow_radius, 55 | sigma=args.shadow_sigma, xoffset=args.shadow_x, yoffset=args.shadow_y) 56 | result = to_x11(cursor.frames) 57 | output = os.path.join(args.output, os.path.splitext(os.path.basename(name))[0]) 58 | with open(output, 'wb') as f: 59 | f.write(result) 60 | 61 | with ThreadPool(cpu_count()) as pool: 62 | pool.map(process, args.files) 63 | 64 | 65 | if __name__ == '__main__': 66 | main() 67 | -------------------------------------------------------------------------------- /win2xcur/main/x2wincur.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | import traceback 5 | from multiprocessing import cpu_count 6 | from multiprocessing.pool import ThreadPool 7 | from threading import Lock 8 | from typing import BinaryIO 9 | 10 | from win2xcur import scale 11 | from win2xcur.parser import open_blob 12 | from win2xcur.writer import to_smart 13 | 14 | 15 | def main() -> None: 16 | parser = argparse.ArgumentParser(description='Converts Windows cursors to X11 cursors.') 17 | parser.add_argument('files', type=argparse.FileType('rb'), nargs='+', 18 | help='X11 cursor files to convert (no extension)') 19 | parser.add_argument('-o', '--output', '--output-dir', default=os.curdir, 20 | help='Directory to store converted cursor files.') 21 | parser.add_argument('-S', '--scale', default=None, type=float, 22 | help='Scale the cursor by the specified factor.') 23 | 24 | args = parser.parse_args() 25 | print_lock = Lock() 26 | 27 | def process(file: BinaryIO) -> None: 28 | name = file.name 29 | blob = file.read() 30 | try: 31 | cursor = open_blob(blob) 32 | except Exception: 33 | with print_lock: 34 | print(f'Error occurred while processing {name}:', file=sys.stderr) 35 | traceback.print_exc() 36 | else: 37 | if args.scale: 38 | scale.apply_to_frames(cursor.frames, scale=args.scale) 39 | ext, result = to_smart(cursor.frames) 40 | output = os.path.join(args.output, os.path.basename(name) + ext) 41 | with open(output, 'wb') as f: 42 | f.write(result) 43 | 44 | with ThreadPool(cpu_count()) as pool: 45 | pool.map(process, args.files) 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | -------------------------------------------------------------------------------- /win2xcur/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from win2xcur.parser.ani import ANIParser 4 | from win2xcur.parser.base import BaseParser 5 | from win2xcur.parser.cur import CURParser 6 | from win2xcur.parser.xcursor import XCursorParser 7 | 8 | __all__ = ['ANIParser', 'CURParser', 'XCursorParser', 'PARSERS', 'open_blob'] 9 | 10 | PARSERS: List[Type[BaseParser]] = [CURParser, ANIParser, XCursorParser] 11 | 12 | 13 | def open_blob(blob: bytes) -> BaseParser: 14 | for parser in PARSERS: 15 | if parser.can_parse(blob): 16 | return parser(blob) 17 | raise ValueError('Unsupported file format') 18 | -------------------------------------------------------------------------------- /win2xcur/parser/ani.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from copy import copy 3 | from typing import Any, Iterable, List, Tuple 4 | 5 | from win2xcur.cursor import CursorFrame 6 | from win2xcur.parser.base import BaseParser 7 | from win2xcur.parser.cur import CURParser 8 | 9 | 10 | class ANIParser(BaseParser): 11 | SIGNATURE = b'RIFF' 12 | ANI_TYPE = b'ACON' 13 | HEADER_CHUNK = b'anih' 14 | LIST_CHUNK = b'LIST' 15 | SEQ_CHUNK = b'seq ' 16 | RATE_CHUNK = b'rate' 17 | FRAME_TYPE = b'fram' 18 | ICON_CHUNK = b'icon' 19 | RIFF_HEADER = struct.Struct('<4sI4s') 20 | CHUNK_HEADER = struct.Struct('<4sI') 21 | ANIH_HEADER = struct.Struct(' bool: 28 | signature: bytes 29 | size: int 30 | subtype: bytes 31 | try: 32 | signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size]) 33 | except struct.error: 34 | return False 35 | return signature == cls.SIGNATURE and subtype == cls.ANI_TYPE 36 | 37 | def __init__(self, blob: bytes) -> None: 38 | super().__init__(blob) 39 | if not self.can_parse(blob): 40 | raise ValueError('Not a .ani file') 41 | self.frames = self._parse(self.RIFF_HEADER.size) 42 | 43 | def _unpack(self, struct_cls: struct.Struct, offset: int) -> Tuple[Any, ...]: 44 | return struct_cls.unpack(self.blob[offset:offset + struct_cls.size]) 45 | 46 | def _read_chunk(self, offset: int, expected: Iterable[bytes]) -> Tuple[bytes, int, int]: 47 | found = [] 48 | while True: 49 | name, size = self._unpack(self.CHUNK_HEADER, offset) 50 | offset += self.CHUNK_HEADER.size 51 | if name in expected: 52 | break 53 | found += [name] 54 | offset += size 55 | if offset >= len(self.blob): 56 | raise ValueError(f'Expected chunk {expected!r}, found {found!r}') 57 | return name, size, offset 58 | 59 | def _parse(self, offset: int) -> List[CursorFrame]: 60 | _, size, offset = self._read_chunk(offset, expected=[self.HEADER_CHUNK]) 61 | 62 | if size != self.ANIH_HEADER.size: 63 | raise ValueError(f'Unexpected anih header size {size}, expected {self.ANIH_HEADER.size}') 64 | 65 | size, frame_count, step_count, width, height, bit_count, planes, display_rate, flags = self.ANIH_HEADER.unpack( 66 | self.blob[offset:offset + self.ANIH_HEADER.size]) 67 | 68 | if size != self.ANIH_HEADER.size: 69 | raise ValueError(f'Unexpected size in anih header {size}, expected {self.ANIH_HEADER.size}') 70 | 71 | if not flags & self.ICON_FLAG: 72 | raise NotImplementedError('Raw BMP images not supported.') 73 | 74 | offset += self.ANIH_HEADER.size 75 | 76 | frames = [] 77 | order = list(range(frame_count)) 78 | delays = [display_rate for _ in range(step_count)] 79 | 80 | while offset < len(self.blob): 81 | name, size, offset = self._read_chunk(offset, expected=[self.LIST_CHUNK, self.SEQ_CHUNK, self.RATE_CHUNK]) 82 | if name == self.LIST_CHUNK: 83 | list_end = offset + size 84 | if self.blob[offset:offset + 4] != self.FRAME_TYPE: 85 | raise ValueError( 86 | f'Unexpected RIFF list type: {self.blob[offset:offset + 4]!r}, expected {self.FRAME_TYPE!r}') 87 | offset += 4 88 | 89 | for i in range(frame_count): 90 | _, size, offset = self._read_chunk(offset, expected=[self.ICON_CHUNK]) 91 | frames.append(CURParser(self.blob[offset:offset + size]).frames[0]) 92 | offset += size 93 | if offset & 1: 94 | offset += 1 95 | 96 | if offset != list_end: 97 | raise ValueError(f'Wrong RIFF list size: {offset}, expected {list_end}') 98 | elif name == self.SEQ_CHUNK: 99 | order = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])] 100 | if len(order) != step_count: 101 | raise ValueError(f'Wrong animation sequence size: {len(order)}, expected {step_count}') 102 | offset += size 103 | elif name == self.RATE_CHUNK: 104 | delays = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])] 105 | if len(delays) != step_count: 106 | raise ValueError(f'Wrong animation rate size: {len(delays)}, expected {step_count}') 107 | offset += size 108 | 109 | if len(order) != step_count: 110 | raise ValueError('Required chunk "seq " not found.') 111 | 112 | sequence = [copy(frames[i]) for i in order] 113 | for frame, delay in zip(sequence, delays): 114 | frame.delay = delay / 60 115 | 116 | return sequence 117 | -------------------------------------------------------------------------------- /win2xcur/parser/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import List 3 | 4 | from win2xcur.cursor import CursorFrame 5 | 6 | 7 | class BaseParser(metaclass=ABCMeta): 8 | blob: bytes 9 | frames: List[CursorFrame] 10 | 11 | @abstractmethod 12 | def __init__(self, blob: bytes) -> None: 13 | self.blob = blob 14 | 15 | @classmethod 16 | @abstractmethod 17 | def can_parse(cls, blob: bytes) -> bool: 18 | raise NotImplementedError() 19 | -------------------------------------------------------------------------------- /win2xcur/parser/cur.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import List, Tuple 3 | 4 | from wand.image import Image 5 | 6 | from win2xcur.cursor import CursorFrame, CursorImage 7 | from win2xcur.parser.base import BaseParser 8 | 9 | 10 | class CURParser(BaseParser): 11 | MAGIC = b'\0\0\02\0' 12 | ICO_TYPE_CUR = 2 13 | ICON_DIR = struct.Struct(' bool: 18 | return blob[:len(cls.MAGIC)] == cls.MAGIC 19 | 20 | def __init__(self, blob: bytes) -> None: 21 | super().__init__(blob) 22 | self._image = Image(blob=blob, format='cur') 23 | self._hotspots = self._parse_header() 24 | self.frames = [CursorFrame([ 25 | CursorImage(image, hotspot, image.width) for image, hotspot in zip(self._image.sequence, self._hotspots) 26 | ])] 27 | 28 | def _parse_header(self) -> List[Tuple[int, int]]: 29 | reserved, ico_type, image_count = self.ICON_DIR.unpack(self.blob[:self.ICON_DIR.size]) 30 | assert reserved == 0 31 | assert ico_type == self.ICO_TYPE_CUR 32 | assert image_count == len(self._image.sequence) 33 | 34 | offset = self.ICON_DIR.size 35 | hotspots = [] 36 | for i in range(image_count): 37 | width, height, palette, reserved, hx, hy, size, file_offset = self.ICON_DIR_ENTRY.unpack( 38 | self.blob[offset:offset + self.ICON_DIR_ENTRY.size]) 39 | hotspots.append((hx, hy)) 40 | offset += self.ICON_DIR_ENTRY.size 41 | 42 | return hotspots 43 | -------------------------------------------------------------------------------- /win2xcur/parser/xcursor.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from collections import defaultdict 3 | from typing import Any, Dict, List, Tuple, cast 4 | 5 | from wand.image import Image 6 | 7 | from win2xcur.cursor import CursorFrame, CursorImage 8 | from win2xcur.parser.base import BaseParser 9 | 10 | 11 | class XCursorParser(BaseParser): 12 | MAGIC = b'Xcur' 13 | VERSION = 0x1_0000 14 | FILE_HEADER = struct.Struct('<4sIII') 15 | TOC_CHUNK = struct.Struct(' bool: 21 | return blob[:len(cls.MAGIC)] == cls.MAGIC 22 | 23 | def __init__(self, blob: bytes) -> None: 24 | super().__init__(blob) 25 | self.frames = self._parse() 26 | 27 | def _unpack(self, struct_cls: struct.Struct, offset: int) -> Tuple[Any, ...]: 28 | return struct_cls.unpack(self.blob[offset:offset + struct_cls.size]) 29 | 30 | def _parse(self) -> List[CursorFrame]: 31 | magic, header_size, version, toc_size = self._unpack(self.FILE_HEADER, 0) 32 | assert magic == self.MAGIC 33 | 34 | if version != self.VERSION: 35 | raise ValueError(f'Unsupported Xcursor version 0x{version:08x}') 36 | 37 | offset = self.FILE_HEADER.size 38 | chunks: List[Tuple[int, int, int]] = [] 39 | for i in range(toc_size): 40 | chunk_type, chunk_subtype, position = self._unpack(self.TOC_CHUNK, offset) 41 | chunks.append((chunk_type, chunk_subtype, position)) 42 | offset += self.TOC_CHUNK.size 43 | 44 | images_by_size: Dict[int, List[Tuple[CursorImage, int]]] = defaultdict(list) 45 | 46 | for chunk_type, chunk_subtype, position in chunks: 47 | if chunk_type != self.CHUNK_IMAGE: 48 | continue 49 | 50 | size, actual_type, nominal_size, version, width, height, x_offset, y_offset, delay = \ 51 | self._unpack(self.IMAGE_HEADER, position) 52 | delay /= 1000 53 | 54 | if size != self.IMAGE_HEADER.size: 55 | raise ValueError(f'Unexpected size: {size}, expected {self.IMAGE_HEADER.size}') 56 | 57 | if actual_type != chunk_type: 58 | raise ValueError(f'Unexpected chunk type: {actual_type}, expected {chunk_type}') 59 | 60 | if nominal_size != chunk_subtype: 61 | raise ValueError(f'Unexpected nominal size: {nominal_size}, expected {chunk_subtype}') 62 | 63 | if width > 0x7FFF: 64 | raise ValueError(f'Image width too large: {width}') 65 | 66 | if height > 0x7FFF: 67 | raise ValueError(f'Image height too large: {height}') 68 | 69 | if x_offset > width: 70 | raise ValueError(f'Hotspot x-coordinate too large: {x_offset}') 71 | 72 | if y_offset > height: 73 | raise ValueError(f'Hotspot x-coordinate too large: {y_offset}') 74 | 75 | image_start = position + self.IMAGE_HEADER.size 76 | image_size = width * height * 4 77 | blob = self.blob[image_start:image_start + image_size] 78 | if len(blob) != image_size: 79 | raise ValueError(f'Invalid image at {image_start}: expected {image_size} bytes, got {len(blob)} bytes') 80 | 81 | image = Image(width=width, height=height) 82 | image.import_pixels(channel_map='BGRA', data=blob) 83 | images_by_size[nominal_size].append( 84 | (CursorImage(image.sequence[0], (x_offset, y_offset), nominal_size), delay) 85 | ) 86 | 87 | if len(set(map(len, images_by_size.values()))) != 1: 88 | raise ValueError('win2xcur does not support animations where each size has different number of frames') 89 | 90 | result = [] 91 | for sequence in cast(Any, zip(*images_by_size.values())): 92 | images: Tuple[CursorImage, ...] 93 | delays: Tuple[int, ...] 94 | images, delays = cast(Any, zip(*sequence)) 95 | 96 | if len(set(delays)) != 1: 97 | raise ValueError('win2xcur does not support animations where each size has a different frame delay') 98 | 99 | result.append(CursorFrame(list(images), delays[0])) 100 | 101 | return result 102 | -------------------------------------------------------------------------------- /win2xcur/scale.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from win2xcur.cursor import CursorFrame 4 | 5 | 6 | def apply_to_frames(frames: List[CursorFrame], *, scale: float) -> None: 7 | for frame in frames: 8 | for cursor in frame: 9 | cursor.image.scale( 10 | int(round(cursor.image.width * scale)), 11 | int(round(cursor.image.height) * scale), 12 | ) 13 | -------------------------------------------------------------------------------- /win2xcur/shadow.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from wand.color import Color 4 | from wand.image import BaseImage, COMPOSITE_OPERATORS, Image 5 | 6 | from win2xcur.cursor import CursorFrame 7 | 8 | if 'copy_opacity' in COMPOSITE_OPERATORS: 9 | COPY_ALPHA = 'copy_opacity' # ImageMagick 6 name 10 | NEEDS_NEGATE = False 11 | else: 12 | COPY_ALPHA = 'copy_alpha' # ImageMagick 7 name 13 | NEEDS_NEGATE = True 14 | 15 | 16 | def apply_to_image(image: BaseImage, *, color: str, radius: float, sigma: float, xoffset: float, 17 | yoffset: float) -> Image: 18 | xoffset = round(xoffset * image.width) 19 | yoffset = round(yoffset * image.height) 20 | new_width = image.width + 3 * xoffset 21 | new_height = image.height + 3 * yoffset 22 | 23 | if NEEDS_NEGATE: 24 | channel = image.channel_images['opacity'].clone() 25 | channel.negate() 26 | else: 27 | channel = image.channel_images['opacity'] 28 | 29 | opacity = Image(width=new_width, height=new_height, pseudo='xc:white') 30 | opacity.composite(channel, left=xoffset, top=yoffset) 31 | opacity.gaussian_blur(radius * image.width, sigma * image.width) 32 | opacity.negate() 33 | opacity.modulate(50) 34 | 35 | shadow = Image(width=new_width, height=new_height, pseudo='xc:' + color) 36 | shadow.composite(opacity, operator=COPY_ALPHA) 37 | 38 | result = Image(width=new_width, height=new_height, pseudo='xc:transparent') 39 | result.composite(shadow) 40 | result.composite(image) 41 | 42 | trimmed = result.clone() 43 | trimmed.trim(color=Color('transparent')) 44 | result.crop(width=max(image.width, trimmed.width), height=max(image.height, trimmed.height)) 45 | return result 46 | 47 | 48 | def apply_to_frames(frames: List[CursorFrame], *, color: str, radius: float, 49 | sigma: float, xoffset: float, yoffset: float) -> None: 50 | for frame in frames: 51 | for cursor in frame: 52 | cursor.image = apply_to_image(cursor.image, color=color, radius=radius, 53 | sigma=sigma, xoffset=xoffset, yoffset=yoffset) 54 | -------------------------------------------------------------------------------- /win2xcur/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import numpy as np 4 | 5 | 6 | def premultiply_alpha(source: bytes) -> bytes: 7 | buffer: np.ndarray[Any, np.dtype[np.double]] = np.frombuffer(source, dtype=np.uint8).astype(np.double) 8 | alpha = buffer[3::4] / 255.0 9 | buffer[0::4] *= alpha 10 | buffer[1::4] *= alpha 11 | buffer[2::4] *= alpha 12 | return buffer.astype(np.uint8).tobytes() 13 | -------------------------------------------------------------------------------- /win2xcur/writer/__init__.py: -------------------------------------------------------------------------------- 1 | from win2xcur.writer.windows import to_ani, to_cur, to_smart 2 | from win2xcur.writer.x11 import to_x11 3 | 4 | __all__ = ['to_ani', 'to_cur', 'to_smart', 'to_x11'] 5 | 6 | CONVERTERS = { 7 | 'x11': (to_x11, ''), 8 | 'ani': (to_ani, '.ani'), 9 | 'cur': (to_cur, '.cur'), 10 | } 11 | -------------------------------------------------------------------------------- /win2xcur/writer/windows.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from itertools import chain 3 | from typing import List, Tuple 4 | 5 | from win2xcur.cursor import CursorFrame 6 | from win2xcur.parser import ANIParser, CURParser 7 | 8 | 9 | def to_cur(frame: CursorFrame) -> bytes: 10 | header = CURParser.ICON_DIR.pack(0, CURParser.ICO_TYPE_CUR, len(frame)) 11 | directory: List[bytes] = [] 12 | image_data: List[bytes] = [] 13 | offset = CURParser.ICON_DIR.size + len(frame) * CURParser.ICON_DIR_ENTRY.size 14 | 15 | for image in frame: 16 | clone = image.image.clone() 17 | if clone.width > 256 or clone.height > 256: 18 | raise ValueError(f'Image too big for CUR format: {clone.width}x{clone.height}') 19 | blob = clone.make_blob('png') 20 | image_data.append(blob) 21 | x_offset, y_offset = image.hotspot 22 | directory.append(CURParser.ICON_DIR_ENTRY.pack( 23 | clone.height & 0xFF, clone.height & 0xFF, 0, 0, x_offset, y_offset, len(blob), offset 24 | )) 25 | offset += len(blob) 26 | 27 | return b''.join(chain([header], directory, image_data)) 28 | 29 | 30 | def get_ani_cur_list(frames: List[CursorFrame]) -> bytes: 31 | io = BytesIO() 32 | for frame in frames: 33 | cur_file = to_cur(frame) 34 | io.write(ANIParser.CHUNK_HEADER.pack(ANIParser.ICON_CHUNK, len(cur_file))) 35 | io.write(cur_file) 36 | if len(cur_file) & 1: 37 | io.write(b'\0') 38 | return io.getvalue() 39 | 40 | 41 | def get_ani_rate_chunk(frames: List[CursorFrame]) -> bytes: 42 | io = BytesIO() 43 | io.write(ANIParser.CHUNK_HEADER.pack(ANIParser.RATE_CHUNK, ANIParser.UNSIGNED.size * len(frames))) 44 | for frame in frames: 45 | io.write(ANIParser.UNSIGNED.pack(int(round(frame.delay * 60)))) 46 | return io.getvalue() 47 | 48 | 49 | def to_ani(frames: List[CursorFrame]) -> bytes: 50 | ani_header = ANIParser.ANIH_HEADER.pack( 51 | ANIParser.ANIH_HEADER.size, len(frames), len(frames), 0, 0, 32, 1, 1, ANIParser.ICON_FLAG 52 | ) 53 | 54 | cur_list = get_ani_cur_list(frames) 55 | chunks = [ 56 | ANIParser.CHUNK_HEADER.pack(ANIParser.HEADER_CHUNK, len(ani_header)), 57 | ani_header, 58 | ANIParser.RIFF_HEADER.pack(ANIParser.LIST_CHUNK, len(cur_list) + 4, ANIParser.FRAME_TYPE), 59 | cur_list, 60 | get_ani_rate_chunk(frames), 61 | ] 62 | body = b''.join(chunks) 63 | riff_header: bytes = ANIParser.RIFF_HEADER.pack(ANIParser.SIGNATURE, len(body) + 4, ANIParser.ANI_TYPE) 64 | return riff_header + body 65 | 66 | 67 | def to_smart(frames: List[CursorFrame]) -> Tuple[str, bytes]: 68 | if len(frames) == 1: 69 | return '.cur', to_cur(frames[0]) 70 | else: 71 | return '.ani', to_ani(frames) 72 | -------------------------------------------------------------------------------- /win2xcur/writer/x11.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from operator import itemgetter 3 | from typing import List 4 | 5 | from win2xcur.cursor import CursorFrame 6 | from win2xcur.parser import XCursorParser 7 | from win2xcur.utils import premultiply_alpha 8 | 9 | 10 | def to_x11(frames: List[CursorFrame]) -> bytes: 11 | chunks = [] 12 | 13 | for frame in frames: 14 | for cursor in frame: 15 | hx, hy = cursor.hotspot 16 | header = XCursorParser.IMAGE_HEADER.pack( 17 | XCursorParser.IMAGE_HEADER.size, 18 | XCursorParser.CHUNK_IMAGE, 19 | cursor.nominal, 20 | 1, 21 | cursor.image.width, 22 | cursor.image.height, 23 | hx, 24 | hy, 25 | int(frame.delay * 1000), 26 | ) 27 | chunks.append(( 28 | XCursorParser.CHUNK_IMAGE, 29 | cursor.nominal, 30 | header + premultiply_alpha(bytes(cursor.image.export_pixels(channel_map='BGRA'))) 31 | )) 32 | 33 | header = XCursorParser.FILE_HEADER.pack( 34 | XCursorParser.MAGIC, 35 | XCursorParser.FILE_HEADER.size, 36 | XCursorParser.VERSION, 37 | len(chunks), 38 | ) 39 | 40 | offset = XCursorParser.FILE_HEADER.size + len(chunks) * XCursorParser.TOC_CHUNK.size 41 | toc = [] 42 | for chunk_type, chunk_subtype, chunk in chunks: 43 | toc.append(XCursorParser.TOC_CHUNK.pack( 44 | chunk_type, 45 | chunk_subtype, 46 | offset, 47 | )) 48 | offset += len(chunk) 49 | 50 | return b''.join(chain([header], toc, map(itemgetter(2), chunks))) 51 | --------------------------------------------------------------------------------