mirror of
https://github.com/quantum5/win2xcur.git
synced 2025-04-24 10:11:57 -04:00
Compare commits
No commits in common. "master" and "v0.0.1" have entirely different histories.
2
.flake8
2
.flake8
|
@ -1,4 +1,2 @@
|
|||
[flake8]
|
||||
max-line-length = 120
|
||||
application-import-names = win2xcur
|
||||
import-order-style = pycharm
|
||||
|
|
26
.github/workflows/build.yml
vendored
26
.github/workflows/build.yml
vendored
|
@ -9,30 +9,22 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ 3.7, 3.8, 3.9, '3.10' ]
|
||||
python-version: [ 3.5, 3.6, 3.7, 3.8 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Cache wheels
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 flake8-import-order mypy wheel coverage
|
||||
pip install flake8 mypy wheel
|
||||
pip install -r requirements.txt
|
||||
sudo apt-get install dmz-cursor-theme
|
||||
sudo apt-get install x11-apps
|
||||
- name: Lint with flake8
|
||||
run: flake8 .
|
||||
- name: Typecheck with mypy
|
||||
if: matrix.python-version != 3.7
|
||||
run: mypy .
|
||||
- name: Test packages
|
||||
run: python setup.py sdist bdist_wheel
|
||||
|
@ -40,20 +32,12 @@ jobs:
|
|||
run: pip install dist/*.whl
|
||||
- name: Test with sample/crosshair.cur
|
||||
run: |
|
||||
coverage run -m win2xcur.main.win2xcur sample/crosshair.cur -o /tmp
|
||||
win2xcur sample/crosshair.cur -o /tmp
|
||||
ls -l /tmp/crosshair
|
||||
- name: Test with animated cursors
|
||||
run: |
|
||||
wget http://www.anicursor.com/waiting.zip
|
||||
mkdir ani output
|
||||
unzip waiting.zip -d ani
|
||||
coverage run -a -m win2xcur.main.win2xcur -s ani/*.ani -o output
|
||||
win2xcur -s sample/crosshair.cur -o output
|
||||
ls -l output/*
|
||||
- name: Test with dmz-cursor-theme
|
||||
run: |
|
||||
mkdir dmz-white
|
||||
coverage run -a -m win2xcur.main.x2wincur /usr/share/icons/DMZ-White/cursors/* -o dmz-white
|
||||
ls -l dmz-white/*
|
||||
- name: Generating coverage report
|
||||
run: coverage xml
|
||||
- uses: codecov/codecov-action@v1
|
||||
|
|
26
README.md
26
README.md
|
@ -1,4 +1,4 @@
|
|||
# `win2xcur` and `x2wincur` [](https://github.com/quantum5/win2xcur/actions) [](https://pypi.org/project/win2xcur/) [](https://pypi.org/project/win2xcur/) [](https://pypi.org/project/win2xcur/)
|
||||
# `win2xcur` [](https://github.com/quantum5/win2xcur/actions) [](https://pypi.org/project/win2xcur/) [](https://pypi.org/project/win2xcur/) [](https://pypi.org/project/win2xcur/)
|
||||
|
||||
`win2xcur` is a tool that converts cursors from Windows format (`*.cur`,
|
||||
`*.ani`) to Xcursor format. This allows Windows cursor themes to be used on
|
||||
|
@ -8,10 +8,6 @@ Linux, for example.
|
|||
hotspot and animation delay, and has an optional mode to add shadows that
|
||||
replicates Windows's cursor shadow effect.
|
||||
|
||||
`x2wincur` is a tool that does the opposite: it converts cursors in the Xcursor
|
||||
format to Windows format (`*.cur`, `*.ani`), allowing to use your favourite
|
||||
Linux cursor themes on Windows.
|
||||
|
||||
## Installation
|
||||
|
||||
To install the latest stable version:
|
||||
|
@ -22,10 +18,9 @@ To install from GitHub:
|
|||
|
||||
pip install -e git+https://github.com/quantum5/win2xcur.git
|
||||
|
||||
## Usage: `win2xcur`
|
||||
## Usage
|
||||
|
||||
For example, if you want to convert [the sample cursor](sample/crosshair.cur)
|
||||
to Linux format:
|
||||
For example, if you want to convert [the sample cursor](sample/crosshair.cur):
|
||||
|
||||
mkdir output/
|
||||
win2xcur sample/crosshair.cur -o output/
|
||||
|
@ -37,18 +32,3 @@ For example, to convert a directory of cursors with shadows enabled:
|
|||
win2xcur input/*.{ani,cur} -o output/
|
||||
|
||||
For more information, run `win2xcur --help`.
|
||||
|
||||
## Usage: `x2wincur`
|
||||
|
||||
For example, if you want to convert DMZ-White to Windows:
|
||||
|
||||
mkdir dmz-white/
|
||||
x2wincur /usr/share/icons/DMZ-White/cursors/* -o dmz-white/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`win2xcur` and `x2wincur` should work out of the box on most systems. If you
|
||||
are using unconventional distros (e.g. Alpine) and are getting errors related
|
||||
to `wand`, please see the [Wand documentation on installation][wand-install].
|
||||
|
||||
[wand-install]: https://docs.wand-py.org/en/0.6.7/guide/install.html
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
coverage:
|
||||
status:
|
||||
project: off
|
||||
patch: off
|
2
mypy.ini
2
mypy.ini
|
@ -1,4 +1,2 @@
|
|||
[mypy]
|
||||
ignore_missing_imports = True
|
||||
strict = true
|
||||
plugins = numpy.typing.mypy_plugin
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
numpy
|
||||
Wand
|
||||
|
|
9
setup.py
9
setup.py
|
@ -1,20 +1,19 @@
|
|||
import os
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f:
|
||||
long_description = f.read()
|
||||
|
||||
setup(
|
||||
name='win2xcur',
|
||||
version='0.1.2',
|
||||
version='0.0.1',
|
||||
packages=find_packages(),
|
||||
install_requires=['numpy', 'Wand'],
|
||||
install_requires=['Wand'],
|
||||
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'win2xcur = win2xcur.main.win2xcur:main',
|
||||
'x2wincur = win2xcur.main.x2wincur:main',
|
||||
'win2xcur = win2xcur.main:main',
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -4,28 +4,20 @@ from wand.sequence import SingleImage
|
|||
|
||||
|
||||
class CursorImage:
|
||||
image: SingleImage
|
||||
hotspot: Tuple[int, int]
|
||||
nominal: int
|
||||
|
||||
def __init__(self, image: SingleImage, hotspot: Tuple[int, int], nominal: int) -> None:
|
||||
def __init__(self, image: SingleImage, hotspot: Tuple[int, int]) -> None:
|
||||
self.image = image
|
||||
self.hotspot = hotspot
|
||||
self.nominal = nominal
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'CursorImage(image={self.image!r}, hotspot={self.hotspot!r}, nominal={self.nominal!r})'
|
||||
return 'CursorImage(image=%r, hotspot=%r)' % (self.image, self.hotspot)
|
||||
|
||||
|
||||
class CursorFrame:
|
||||
images: List[CursorImage]
|
||||
delay: int
|
||||
|
||||
def __init__(self, images: List[CursorImage], delay: int = 0) -> None:
|
||||
def __init__(self, images: List[CursorImage], delay=0) -> None:
|
||||
self.images = images
|
||||
self.delay = delay
|
||||
|
||||
def __getitem__(self, item: int) -> CursorImage:
|
||||
def __getitem__(self, item) -> CursorImage:
|
||||
return self.images[item]
|
||||
|
||||
def __len__(self) -> int:
|
||||
|
@ -35,4 +27,4 @@ class CursorFrame:
|
|||
return iter(self.images)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'CursorFrame(images={self.images!r}, delay={self.delay!r})'
|
||||
return 'CursorFrame(images=%r, delay=%r)' % (self.images, self.delay)
|
||||
|
|
|
@ -5,11 +5,10 @@ import traceback
|
|||
from multiprocessing import cpu_count
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from threading import Lock
|
||||
from typing import BinaryIO
|
||||
|
||||
from win2xcur import scale, shadow
|
||||
from win2xcur import shadow
|
||||
from win2xcur.parser import open_blob
|
||||
from win2xcur.writer import to_x11
|
||||
from win2xcur.writer.x11 import check_xcursorgen, to_x11
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
@ -29,27 +28,25 @@ def main() -> None:
|
|||
parser.add_argument('-x', '--shadow-x', type=float, default=0.05,
|
||||
help='x-offset of shadow (as fraction of width)')
|
||||
parser.add_argument('-y', '--shadow-y', type=float, default=0.05,
|
||||
help='y-offset of shadow (as fraction of height)')
|
||||
help='x-offset of shadow (as fraction of height)')
|
||||
parser.add_argument('-c', '--shadow-color', default='#000000',
|
||||
help='color of the shadow')
|
||||
parser.add_argument('--scale', default=None, type=float,
|
||||
help='Scale the cursor by the specified factor.')
|
||||
|
||||
args = parser.parse_args()
|
||||
print_lock = Lock()
|
||||
|
||||
def process(file: BinaryIO) -> None:
|
||||
check_xcursorgen()
|
||||
|
||||
def process(file):
|
||||
name = file.name
|
||||
blob = file.read()
|
||||
try:
|
||||
cursor = open_blob(blob)
|
||||
except Exception:
|
||||
with print_lock:
|
||||
print(f'Error occurred while processing {name}:', file=sys.stderr)
|
||||
print('Error occurred while processing %s:' % (name,), file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if args.scale:
|
||||
scale.apply_to_frames(cursor.frames, scale=args.scale)
|
||||
if args.shadow:
|
||||
shadow.apply_to_frames(cursor.frames, color=args.shadow_color, radius=args.shadow_radius,
|
||||
sigma=args.shadow_sigma, xoffset=args.shadow_x, yoffset=args.shadow_y)
|
|
@ -1,49 +0,0 @@
|
|||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from multiprocessing import cpu_count
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from threading import Lock
|
||||
from typing import BinaryIO
|
||||
|
||||
from win2xcur import scale
|
||||
from win2xcur.parser import open_blob
|
||||
from win2xcur.writer import to_smart
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description='Converts Windows cursors to X11 cursors.')
|
||||
parser.add_argument('files', type=argparse.FileType('rb'), nargs='+',
|
||||
help='X11 cursor files to convert (no extension)')
|
||||
parser.add_argument('-o', '--output', '--output-dir', default=os.curdir,
|
||||
help='Directory to store converted cursor files.')
|
||||
parser.add_argument('-S', '--scale', default=None, type=float,
|
||||
help='Scale the cursor by the specified factor.')
|
||||
|
||||
args = parser.parse_args()
|
||||
print_lock = Lock()
|
||||
|
||||
def process(file: BinaryIO) -> None:
|
||||
name = file.name
|
||||
blob = file.read()
|
||||
try:
|
||||
cursor = open_blob(blob)
|
||||
except Exception:
|
||||
with print_lock:
|
||||
print(f'Error occurred while processing {name}:', file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if args.scale:
|
||||
scale.apply_to_frames(cursor.frames, scale=args.scale)
|
||||
ext, result = to_smart(cursor.frames)
|
||||
output = os.path.join(args.output, os.path.basename(name) + ext)
|
||||
with open(output, 'wb') as f:
|
||||
f.write(result)
|
||||
|
||||
with ThreadPool(cpu_count()) as pool:
|
||||
pool.map(process, args.files)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,16 +1,10 @@
|
|||
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
|
||||
|
||||
__all__ = ['ANIParser', 'CURParser', 'XCursorParser', 'PARSERS', 'open_blob']
|
||||
|
||||
PARSERS: List[Type[BaseParser]] = [CURParser, ANIParser, XCursorParser]
|
||||
PARSERS = [CURParser, ANIParser]
|
||||
|
||||
|
||||
def open_blob(blob: bytes) -> BaseParser:
|
||||
def open_blob(blob):
|
||||
for parser in PARSERS:
|
||||
if parser.can_parse(blob):
|
||||
return parser(blob)
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
import struct
|
||||
from copy import copy
|
||||
from typing import Any, Iterable, List, Tuple
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
from win2xcur.parser.base import BaseParser
|
||||
from win2xcur.parser.cur import CURParser
|
||||
|
||||
|
||||
class ANIParser(BaseParser):
|
||||
class ANIParser:
|
||||
SIGNATURE = b'RIFF'
|
||||
ANI_TYPE = b'ACON'
|
||||
HEADER_CHUNK = b'anih'
|
||||
LIST_CHUNK = b'LIST'
|
||||
SEQ_CHUNK = b'seq '
|
||||
RATE_CHUNK = b'rate'
|
||||
FRAME_TYPE = b'fram'
|
||||
ICON_CHUNK = b'icon'
|
||||
RIFF_HEADER = struct.Struct('<4sI4s')
|
||||
CHUNK_HEADER = struct.Struct('<4sI')
|
||||
ANIH_HEADER = struct.Struct('<IIIIIIIII')
|
||||
|
@ -24,92 +16,70 @@ class ANIParser(BaseParser):
|
|||
ICON_FLAG = 0x1
|
||||
|
||||
@classmethod
|
||||
def can_parse(cls, blob: bytes) -> bool:
|
||||
signature: bytes
|
||||
size: int
|
||||
subtype: bytes
|
||||
try:
|
||||
signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size])
|
||||
except struct.error:
|
||||
return False
|
||||
return signature == cls.SIGNATURE and subtype == cls.ANI_TYPE
|
||||
def can_parse(cls, blob):
|
||||
signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size])
|
||||
return signature == cls.SIGNATURE and size == len(blob) - 8 and subtype == cls.ANI_TYPE
|
||||
|
||||
def __init__(self, blob: bytes) -> None:
|
||||
super().__init__(blob)
|
||||
def __init__(self, blob):
|
||||
self.blob = blob
|
||||
if not self.can_parse(blob):
|
||||
raise ValueError('Not a .ani file')
|
||||
self.frames = self._parse(self.RIFF_HEADER.size)
|
||||
|
||||
def _unpack(self, struct_cls: struct.Struct, offset: int) -> Tuple[Any, ...]:
|
||||
def _unpack(self, struct_cls, offset):
|
||||
return struct_cls.unpack(self.blob[offset:offset + struct_cls.size])
|
||||
|
||||
def _read_chunk(self, offset: int, expected: Iterable[bytes]) -> Tuple[bytes, int, int]:
|
||||
found = []
|
||||
while True:
|
||||
name, size = self._unpack(self.CHUNK_HEADER, offset)
|
||||
offset += self.CHUNK_HEADER.size
|
||||
if name in expected:
|
||||
break
|
||||
found += [name]
|
||||
offset += size
|
||||
if offset >= len(self.blob):
|
||||
raise ValueError(f'Expected chunk {expected!r}, found {found!r}')
|
||||
return name, size, offset
|
||||
def _read_chunk(self, offset, expected):
|
||||
name, size = self._unpack(self.CHUNK_HEADER, offset)
|
||||
if name not in expected:
|
||||
raise ValueError('Expected chunk %r, found %r' % (expected, name))
|
||||
return size, offset + self.CHUNK_HEADER.size
|
||||
|
||||
def _parse(self, offset: int) -> List[CursorFrame]:
|
||||
_, size, offset = self._read_chunk(offset, expected=[self.HEADER_CHUNK])
|
||||
def _parse(self, offset):
|
||||
size, offset = self._read_chunk(offset, expected=[b'anih'])
|
||||
|
||||
if size != self.ANIH_HEADER.size:
|
||||
raise ValueError(f'Unexpected anih header size {size}, expected {self.ANIH_HEADER.size}')
|
||||
raise ValueError('Unexpected anih header size %d, expected %d' % (size, self.ANIH_HEADER.size))
|
||||
|
||||
size, frame_count, step_count, width, height, bit_count, planes, display_rate, flags = self.ANIH_HEADER.unpack(
|
||||
self.blob[offset:offset + self.ANIH_HEADER.size])
|
||||
|
||||
if size != self.ANIH_HEADER.size:
|
||||
raise ValueError(f'Unexpected size in anih header {size}, expected {self.ANIH_HEADER.size}')
|
||||
|
||||
if not flags & self.ICON_FLAG:
|
||||
raise NotImplementedError('Raw BMP images not supported.')
|
||||
|
||||
offset += self.ANIH_HEADER.size
|
||||
list_size, offset = self._read_chunk(offset, expected=[b'LIST'])
|
||||
list_end = list_size + offset
|
||||
|
||||
if self.blob[offset:offset + 4] != self.FRAME_TYPE:
|
||||
raise ValueError('Unexpected RIFF list type: %r, expected %r' %
|
||||
(self.blob[offset:offset + 4], self.FRAME_TYPE))
|
||||
offset += 4
|
||||
|
||||
frames = []
|
||||
order = list(range(frame_count))
|
||||
for i in range(frame_count):
|
||||
size, offset = self._read_chunk(offset, expected=[b'icon'])
|
||||
frames.append(CURParser(self.blob[offset:offset + size]).frames[0])
|
||||
offset += size
|
||||
|
||||
if offset != list_end:
|
||||
raise ValueError('Wrong RIFF list size: %r, expected %r' % (offset, list_end))
|
||||
|
||||
sequence = frames
|
||||
if flags & self.SEQUENCE_FLAG:
|
||||
size, offset = self._read_chunk(offset, expected=[b'seq '])
|
||||
sequence = [copy(frames[i]) for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])]
|
||||
if len(sequence) != step_count:
|
||||
raise ValueError('Wrong animation sequence size: %r, expected %r' % (len(sequence), step_count))
|
||||
offset += size
|
||||
|
||||
delays = [display_rate for _ in range(step_count)]
|
||||
if offset < len(self.blob):
|
||||
size, offset = self._read_chunk(offset, expected=[b'rate'])
|
||||
delays = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])]
|
||||
if len(sequence) != step_count:
|
||||
raise ValueError('Wrong animation rate size: %r, expected %r' % (len(delays), step_count))
|
||||
|
||||
while offset < len(self.blob):
|
||||
name, size, offset = self._read_chunk(offset, expected=[self.LIST_CHUNK, self.SEQ_CHUNK, self.RATE_CHUNK])
|
||||
if name == self.LIST_CHUNK:
|
||||
list_end = offset + size
|
||||
if self.blob[offset:offset + 4] != self.FRAME_TYPE:
|
||||
raise ValueError(
|
||||
f'Unexpected RIFF list type: {self.blob[offset:offset + 4]!r}, expected {self.FRAME_TYPE!r}')
|
||||
offset += 4
|
||||
|
||||
for i in range(frame_count):
|
||||
_, size, offset = self._read_chunk(offset, expected=[self.ICON_CHUNK])
|
||||
frames.append(CURParser(self.blob[offset:offset + size]).frames[0])
|
||||
offset += size
|
||||
if offset & 1:
|
||||
offset += 1
|
||||
|
||||
if offset != list_end:
|
||||
raise ValueError(f'Wrong RIFF list size: {offset}, expected {list_end}')
|
||||
elif name == self.SEQ_CHUNK:
|
||||
order = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])]
|
||||
if len(order) != step_count:
|
||||
raise ValueError(f'Wrong animation sequence size: {len(order)}, expected {step_count}')
|
||||
offset += size
|
||||
elif name == self.RATE_CHUNK:
|
||||
delays = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])]
|
||||
if len(delays) != step_count:
|
||||
raise ValueError(f'Wrong animation rate size: {len(delays)}, expected {step_count}')
|
||||
offset += size
|
||||
|
||||
if len(order) != step_count:
|
||||
raise ValueError('Required chunk "seq " not found.')
|
||||
|
||||
sequence = [copy(frames[i]) for i in order]
|
||||
for frame, delay in zip(sequence, delays):
|
||||
frame.delay = delay / 60
|
||||
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
from typing import List
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
|
||||
|
||||
class BaseParser(metaclass=ABCMeta):
|
||||
blob: bytes
|
||||
frames: List[CursorFrame]
|
||||
|
||||
@abstractmethod
|
||||
def __init__(self, blob: bytes) -> None:
|
||||
self.blob = blob
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def can_parse(cls, blob: bytes) -> bool:
|
||||
raise NotImplementedError()
|
|
@ -1,34 +1,31 @@
|
|||
import struct
|
||||
from typing import List, Tuple
|
||||
|
||||
from wand.image import Image
|
||||
|
||||
from win2xcur.cursor import CursorFrame, CursorImage
|
||||
from win2xcur.parser.base import BaseParser
|
||||
|
||||
|
||||
class CURParser(BaseParser):
|
||||
class CURParser:
|
||||
MAGIC = b'\0\0\02\0'
|
||||
ICO_TYPE_CUR = 2
|
||||
ICON_DIR = struct.Struct('<HHH')
|
||||
ICON_DIR_ENTRY = struct.Struct('<BBBBHHII')
|
||||
|
||||
@classmethod
|
||||
def can_parse(cls, blob: bytes) -> bool:
|
||||
def can_parse(cls, blob):
|
||||
return blob[:len(cls.MAGIC)] == cls.MAGIC
|
||||
|
||||
def __init__(self, blob: bytes) -> None:
|
||||
super().__init__(blob)
|
||||
def __init__(self, blob):
|
||||
self.blob = blob
|
||||
self._image = Image(blob=blob, format='cur')
|
||||
self._hotspots = self._parse_header()
|
||||
self.frames = [CursorFrame([
|
||||
CursorImage(image, hotspot, image.width) for image, hotspot in zip(self._image.sequence, self._hotspots)
|
||||
CursorImage(image, hotspot) for image, hotspot in zip(self._image.sequence, self._hotspots)
|
||||
])]
|
||||
|
||||
def _parse_header(self) -> List[Tuple[int, int]]:
|
||||
def _parse_header(self):
|
||||
reserved, ico_type, image_count = self.ICON_DIR.unpack(self.blob[:self.ICON_DIR.size])
|
||||
assert reserved == 0
|
||||
assert ico_type == self.ICO_TYPE_CUR
|
||||
assert ico_type == 2
|
||||
assert image_count == len(self._image.sequence)
|
||||
|
||||
offset = self.ICON_DIR.size
|
||||
|
|
|
@ -1,101 +0,0 @@
|
|||
import struct
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Tuple, cast
|
||||
|
||||
from wand.image import Image
|
||||
|
||||
from win2xcur.cursor import CursorFrame, CursorImage
|
||||
from win2xcur.parser.base import BaseParser
|
||||
|
||||
|
||||
class XCursorParser(BaseParser):
|
||||
MAGIC = b'Xcur'
|
||||
VERSION = 0x1_0000
|
||||
FILE_HEADER = struct.Struct('<4sIII')
|
||||
TOC_CHUNK = struct.Struct('<III')
|
||||
CHUNK_IMAGE = 0xFFFD0002
|
||||
IMAGE_HEADER = struct.Struct('<IIIIIIIII')
|
||||
|
||||
@classmethod
|
||||
def can_parse(cls, blob: bytes) -> 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(f'Unsupported Xcursor version 0x{version:08x}')
|
||||
|
||||
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)
|
||||
delay /= 1000
|
||||
|
||||
if size != self.IMAGE_HEADER.size:
|
||||
raise ValueError(f'Unexpected size: {size}, expected {self.IMAGE_HEADER.size}')
|
||||
|
||||
if actual_type != chunk_type:
|
||||
raise ValueError(f'Unexpected chunk type: {actual_type}, expected {chunk_type}')
|
||||
|
||||
if nominal_size != chunk_subtype:
|
||||
raise ValueError(f'Unexpected nominal size: {nominal_size}, expected {chunk_subtype}')
|
||||
|
||||
if width > 0x7FFF:
|
||||
raise ValueError(f'Image width too large: {width}')
|
||||
|
||||
if height > 0x7FFF:
|
||||
raise ValueError(f'Image height too large: {height}')
|
||||
|
||||
if x_offset > width:
|
||||
raise ValueError(f'Hotspot x-coordinate too large: {x_offset}')
|
||||
|
||||
if y_offset > height:
|
||||
raise ValueError(f'Hotspot x-coordinate too large: {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(f'Invalid image at {image_start}: expected {image_size} bytes, got {len(blob)} bytes')
|
||||
|
||||
image = Image(width=width, height=height)
|
||||
image.import_pixels(channel_map='BGRA', data=blob)
|
||||
images_by_size[nominal_size].append(
|
||||
(CursorImage(image.sequence[0], (x_offset, y_offset), nominal_size), 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
|
|
@ -1,12 +0,0 @@
|
|||
from typing import List
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
|
||||
|
||||
def apply_to_frames(frames: List[CursorFrame], *, scale: float) -> None:
|
||||
for frame in frames:
|
||||
for cursor in frame:
|
||||
cursor.image.scale(
|
||||
int(round(cursor.image.width * scale)),
|
||||
int(round(cursor.image.height) * scale),
|
||||
)
|
|
@ -1,53 +1,28 @@
|
|||
from typing import List
|
||||
|
||||
from wand.color import Color
|
||||
from wand.image import BaseImage, COMPOSITE_OPERATORS, Image
|
||||
from wand.image import BaseImage, Image
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
|
||||
if 'copy_opacity' in COMPOSITE_OPERATORS:
|
||||
COPY_ALPHA = 'copy_opacity' # ImageMagick 6 name
|
||||
NEEDS_NEGATE = False
|
||||
else:
|
||||
COPY_ALPHA = 'copy_alpha' # ImageMagick 7 name
|
||||
NEEDS_NEGATE = True
|
||||
|
||||
|
||||
def apply_to_image(image: BaseImage, *, color: str, radius: float, sigma: float, xoffset: float,
|
||||
yoffset: float) -> Image:
|
||||
xoffset = round(xoffset * image.width)
|
||||
yoffset = round(yoffset * image.height)
|
||||
new_width = image.width + 3 * xoffset
|
||||
new_height = image.height + 3 * yoffset
|
||||
|
||||
if NEEDS_NEGATE:
|
||||
channel = image.channel_images['opacity'].clone()
|
||||
channel.negate()
|
||||
else:
|
||||
channel = image.channel_images['opacity']
|
||||
|
||||
opacity = Image(width=new_width, height=new_height, pseudo='xc:white')
|
||||
opacity.composite(channel, left=xoffset, top=yoffset)
|
||||
opacity = Image(width=image.width, height=image.height, pseudo='xc:white')
|
||||
opacity.composite(image.channel_images['opacity'], left=round(xoffset * image.width),
|
||||
top=round(yoffset * image.height))
|
||||
opacity.gaussian_blur(radius * image.width, sigma * image.width)
|
||||
opacity.negate()
|
||||
opacity.modulate(50)
|
||||
|
||||
shadow = Image(width=new_width, height=new_height, pseudo='xc:' + color)
|
||||
shadow.composite(opacity, operator=COPY_ALPHA)
|
||||
shadow = Image(width=image.width, height=image.height, pseudo='xc:' + color)
|
||||
shadow.composite(opacity, operator='copy_opacity')
|
||||
|
||||
result = Image(width=new_width, height=new_height, pseudo='xc:transparent')
|
||||
result.composite(shadow)
|
||||
result.composite(image)
|
||||
|
||||
trimmed = result.clone()
|
||||
trimmed.trim(color=Color('transparent'))
|
||||
result.crop(width=max(image.width, trimmed.width), height=max(image.height, trimmed.height))
|
||||
result = image.clone()
|
||||
result.composite(shadow, operator='difference')
|
||||
return result
|
||||
|
||||
|
||||
def apply_to_frames(frames: List[CursorFrame], *, color: str, radius: float,
|
||||
sigma: float, xoffset: float, yoffset: float) -> None:
|
||||
def apply_to_frames(frames: List[CursorFrame], **kwargs):
|
||||
for frame in frames:
|
||||
for cursor in frame:
|
||||
cursor.image = apply_to_image(cursor.image, color=color, radius=radius,
|
||||
sigma=sigma, xoffset=xoffset, yoffset=yoffset)
|
||||
cursor.image = apply_to_image(cursor.image, **kwargs)
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def premultiply_alpha(source: bytes) -> bytes:
|
||||
buffer: np.ndarray[Any, np.dtype[np.double]] = np.frombuffer(source, dtype=np.uint8).astype(np.double)
|
||||
alpha = buffer[3::4] / 255.0
|
||||
buffer[0::4] *= alpha
|
||||
buffer[1::4] *= alpha
|
||||
buffer[2::4] *= alpha
|
||||
return buffer.astype(np.uint8).tobytes()
|
|
@ -1,10 +1,5 @@
|
|||
from win2xcur.writer.windows import to_ani, to_cur, to_smart
|
||||
from win2xcur.writer.x11 import to_x11
|
||||
|
||||
__all__ = ['to_ani', 'to_cur', 'to_smart', 'to_x11']
|
||||
|
||||
CONVERTERS = {
|
||||
'x11': (to_x11, ''),
|
||||
'ani': (to_ani, '.ani'),
|
||||
'cur': (to_cur, '.cur'),
|
||||
}
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
from io import BytesIO
|
||||
from itertools import chain
|
||||
from typing import List, Tuple
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
from win2xcur.parser import ANIParser, CURParser
|
||||
|
||||
|
||||
def to_cur(frame: CursorFrame) -> bytes:
|
||||
header = CURParser.ICON_DIR.pack(0, CURParser.ICO_TYPE_CUR, len(frame))
|
||||
directory: List[bytes] = []
|
||||
image_data: List[bytes] = []
|
||||
offset = CURParser.ICON_DIR.size + len(frame) * CURParser.ICON_DIR_ENTRY.size
|
||||
|
||||
for image in frame:
|
||||
clone = image.image.clone()
|
||||
if clone.width > 256 or clone.height > 256:
|
||||
raise ValueError(f'Image too big for CUR format: {clone.width}x{clone.height}')
|
||||
blob = clone.make_blob('png')
|
||||
image_data.append(blob)
|
||||
x_offset, y_offset = image.hotspot
|
||||
directory.append(CURParser.ICON_DIR_ENTRY.pack(
|
||||
clone.height & 0xFF, clone.height & 0xFF, 0, 0, x_offset, y_offset, len(blob), offset
|
||||
))
|
||||
offset += len(blob)
|
||||
|
||||
return b''.join(chain([header], directory, image_data))
|
||||
|
||||
|
||||
def get_ani_cur_list(frames: List[CursorFrame]) -> bytes:
|
||||
io = BytesIO()
|
||||
for frame in frames:
|
||||
cur_file = to_cur(frame)
|
||||
io.write(ANIParser.CHUNK_HEADER.pack(ANIParser.ICON_CHUNK, len(cur_file)))
|
||||
io.write(cur_file)
|
||||
if len(cur_file) & 1:
|
||||
io.write(b'\0')
|
||||
return io.getvalue()
|
||||
|
||||
|
||||
def get_ani_rate_chunk(frames: List[CursorFrame]) -> bytes:
|
||||
io = BytesIO()
|
||||
io.write(ANIParser.CHUNK_HEADER.pack(ANIParser.RATE_CHUNK, ANIParser.UNSIGNED.size * len(frames)))
|
||||
for frame in frames:
|
||||
io.write(ANIParser.UNSIGNED.pack(int(round(frame.delay * 60))))
|
||||
return io.getvalue()
|
||||
|
||||
|
||||
def to_ani(frames: List[CursorFrame]) -> bytes:
|
||||
ani_header = ANIParser.ANIH_HEADER.pack(
|
||||
ANIParser.ANIH_HEADER.size, len(frames), len(frames), 0, 0, 32, 1, 1, ANIParser.ICON_FLAG
|
||||
)
|
||||
|
||||
cur_list = get_ani_cur_list(frames)
|
||||
chunks = [
|
||||
ANIParser.CHUNK_HEADER.pack(ANIParser.HEADER_CHUNK, len(ani_header)),
|
||||
ani_header,
|
||||
ANIParser.RIFF_HEADER.pack(ANIParser.LIST_CHUNK, len(cur_list) + 4, ANIParser.FRAME_TYPE),
|
||||
cur_list,
|
||||
get_ani_rate_chunk(frames),
|
||||
]
|
||||
body = b''.join(chunks)
|
||||
riff_header: bytes = ANIParser.RIFF_HEADER.pack(ANIParser.SIGNATURE, len(body) + 4, ANIParser.ANI_TYPE)
|
||||
return riff_header + body
|
||||
|
||||
|
||||
def to_smart(frames: List[CursorFrame]) -> Tuple[str, bytes]:
|
||||
if len(frames) == 1:
|
||||
return '.cur', to_cur(frames[0])
|
||||
else:
|
||||
return '.ani', to_ani(frames)
|
|
@ -1,50 +1,54 @@
|
|||
from itertools import chain
|
||||
from operator import itemgetter
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import List
|
||||
|
||||
from wand.image import Image
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
from win2xcur.parser import XCursorParser
|
||||
from win2xcur.utils import premultiply_alpha
|
||||
|
||||
xcursorgen_checked = False
|
||||
|
||||
|
||||
def check_xcursorgen() -> None:
|
||||
global xcursorgen_checked
|
||||
if xcursorgen_checked:
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.check_call(['xcursorgen', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError:
|
||||
raise RuntimeError('xcursorgen must be installed to create X11 cursors!')
|
||||
else:
|
||||
xcursorgen_checked = True
|
||||
|
||||
|
||||
def to_x11(frames: List[CursorFrame]) -> bytes:
|
||||
chunks = []
|
||||
check_xcursorgen()
|
||||
|
||||
for frame in frames:
|
||||
for cursor in frame:
|
||||
hx, hy = cursor.hotspot
|
||||
header = XCursorParser.IMAGE_HEADER.pack(
|
||||
XCursorParser.IMAGE_HEADER.size,
|
||||
XCursorParser.CHUNK_IMAGE,
|
||||
cursor.nominal,
|
||||
1,
|
||||
cursor.image.width,
|
||||
cursor.image.height,
|
||||
hx,
|
||||
hy,
|
||||
int(frame.delay * 1000),
|
||||
)
|
||||
chunks.append((
|
||||
XCursorParser.CHUNK_IMAGE,
|
||||
cursor.nominal,
|
||||
header + premultiply_alpha(bytes(cursor.image.export_pixels(channel_map='BGRA')))
|
||||
))
|
||||
counter = 0
|
||||
configs = []
|
||||
with TemporaryDirectory() as png_dir:
|
||||
for frame in frames:
|
||||
for cursor in frame:
|
||||
name = '%d.png' % (counter,)
|
||||
hx, hy = cursor.hotspot
|
||||
configs.append('%d %d %d %s %d' % (cursor.image.width, hx, hy, name, int(frame.delay * 1000)))
|
||||
|
||||
header = XCursorParser.FILE_HEADER.pack(
|
||||
XCursorParser.MAGIC,
|
||||
XCursorParser.FILE_HEADER.size,
|
||||
XCursorParser.VERSION,
|
||||
len(chunks),
|
||||
)
|
||||
image = Image(image=cursor.image)
|
||||
image.save(filename=os.path.join(png_dir, name))
|
||||
counter += 1
|
||||
|
||||
offset = XCursorParser.FILE_HEADER.size + len(chunks) * XCursorParser.TOC_CHUNK.size
|
||||
toc = []
|
||||
for chunk_type, chunk_subtype, chunk in chunks:
|
||||
toc.append(XCursorParser.TOC_CHUNK.pack(
|
||||
chunk_type,
|
||||
chunk_subtype,
|
||||
offset,
|
||||
))
|
||||
offset += len(chunk)
|
||||
output_file = os.path.join(png_dir, 'cursor')
|
||||
process = subprocess.Popen(['xcursorgen', '-p', png_dir, '-', output_file], stdin=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
|
||||
return b''.join(chain([header], toc, map(itemgetter(2), chunks)))
|
||||
_, error = process.communicate('\n'.join(configs).encode(sys.getfilesystemencoding()))
|
||||
if process.wait() != 0:
|
||||
raise RuntimeError('xcursorgen failed: %r' % error)
|
||||
|
||||
with open(output_file, 'rb') as f:
|
||||
result = f.read()
|
||||
|
||||
return result
|
||||
|
|
Loading…
Reference in a new issue