Compare commits

..

No commits in common. "master" and "v0.0.1" have entirely different histories.

22 changed files with 130 additions and 515 deletions

View file

@ -1,4 +1,2 @@
[flake8]
max-line-length = 120
application-import-names = win2xcur
import-order-style = pycharm

View file

@ -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

View file

@ -1,4 +1,4 @@
# `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/)
# `win2xcur` [![Build Status](https://img.shields.io/github/workflow/status/quantum5/win2xcur/Python%20package)](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/)
`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

View file

@ -1,4 +0,0 @@
coverage:
status:
project: off
patch: off

View file

@ -1,4 +1,2 @@
[mypy]
ignore_missing_imports = True
strict = true
plugins = numpy.typing.mypy_plugin

View file

@ -1,2 +1 @@
numpy
Wand

View file

@ -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',
],
},

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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:
def can_parse(cls, blob):
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
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:
def _read_chunk(self, offset, expected):
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
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
frames = []
order = list(range(frame_count))
delays = [display_rate for _ in range(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}')
raise ValueError('Unexpected RIFF list type: %r, expected %r' %
(self.blob[offset:offset + 4], self.FRAME_TYPE))
offset += 4
frames = []
for i in range(frame_count):
_, size, offset = self._read_chunk(offset, expected=[self.ICON_CHUNK])
size, offset = self._read_chunk(offset, expected=[b'icon'])
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}')
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
elif name == self.RATE_CHUNK:
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(delays) != step_count:
raise ValueError(f'Wrong animation rate size: {len(delays)}, expected {step_count}')
offset += size
if len(sequence) != step_count:
raise ValueError('Wrong animation rate size: %r, expected %r' % (len(delays), step_count))
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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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),
)

View file

@ -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)

View file

@ -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()

View file

@ -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'),
}

View file

@ -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)

View file

@ -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()
counter = 0
configs = []
with TemporaryDirectory() as png_dir:
for frame in frames:
for cursor in frame:
name = '%d.png' % (counter,)
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')))
))
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