Compare commits

...

24 commits

Author SHA1 Message Date
Quantum 8e71037f5f Add ability to scale cursor images 2024-01-13 07:23:30 -05:00
Quantum 0898f35183 Fix GitHub Actions badge in README.md 2023-08-02 23:42:43 -04:00
Quantum 54ecf8bf90 Release v0.1.2 2023-05-22 22:39:47 -04:00
Quantum 7863775921 Negate opacity channel on ImageMagick 7
In ImageMagick 7, the semantics of the opacity channel is flipped.
2022-04-24 02:13:45 -04:00
Quantum ffb9c5e24a Support copy_opacity rename in ImageMagick 7 2022-04-09 04:06:26 -04:00
Quantum b5308cbb1e Release version 0.1.1 2022-03-28 00:50:04 -04:00
Quantum fcf9681371 Convert Xcursor delays to seconds 2022-03-28 00:48:30 -04:00
Quantum ee3249c705 Fix spelling in README 2022-03-24 01:37:23 -04:00
Quantum 99ff1bb1ba Deal with Python 3.7 not having the right types 2022-03-17 01:50:30 -04:00
Quantum a89e52a286 Fix mypy typing 2022-03-17 01:46:41 -04:00
ful1e5 5c03c08a61 fixed typo inside win2xcur '--shadow-y' option 2022-03-17 01:29:36 -04:00
Quantum b979ed5fc5 Document wand-related issues 2021-09-08 06:38:36 -04:00
Quantum 8f156b27db Release 0.1.0 2021-06-08 16:26:18 -04:00
Quantum ac9552ce83 Relax *.ani file length requirement in RIFF header
Many *.ani files online have incorrect length in the RIFF header, see #5, #6
2021-03-09 15:47:46 -05:00
Quantum 21682f624d Fix README.md typo 2021-02-02 19:29:45 -05:00
Quantum 380125bc2b Cache wheels 2020-10-06 12:37:09 -04:00
Guanzhong Chen 1de5540077
Switch CI to use 3.9 stable (#4) 2020-10-06 12:34:31 -04:00
Quantum 8b1b5c6f80 Convert all string formatting to f-strings 2020-10-04 04:17:20 -04:00
Quantum b4fc7812e4 Premultiply alpha when converting to Xcursor 2020-10-04 03:57:39 -04:00
Quantum b383486d67 Add borders to avoid cropping cursor shadow 2020-10-04 03:07:34 -04:00
Quantum de7534243d Use standard function wand.image.Image.import_pixels 2020-10-04 02:31:27 -04:00
Guanzhong Chen ec5eb36902
Remove dependency on xcursorgen (#3) 2020-10-04 02:30:00 -04:00
Quantum 5c25adc445 Use correct pixel ordering when loading Xcursor 2020-10-04 02:06:54 -04:00
Quantum eda755fe3a Disable codecov status 2020-10-03 15:11:26 -04:00
16 changed files with 163 additions and 107 deletions

View file

@ -9,22 +9,30 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ 3.6, 3.7, 3.8, 3.9.0-alpha - 3.9 ] python-version: [ 3.7, 3.8, 3.9, '3.10' ]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} 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 - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install flake8 flake8-import-order mypy wheel coverage pip install flake8 flake8-import-order mypy wheel coverage
pip install -r requirements.txt pip install -r requirements.txt
sudo apt-get install x11-apps dmz-cursor-theme sudo apt-get install dmz-cursor-theme
- name: Lint with flake8 - name: Lint with flake8
run: flake8 . run: flake8 .
- name: Typecheck with mypy - name: Typecheck with mypy
if: matrix.python-version != 3.7
run: mypy . run: mypy .
- name: Test packages - name: Test packages
run: python setup.py sdist bdist_wheel run: python setup.py sdist bdist_wheel

View file

@ -1,4 +1,4 @@
# `win2xcur` and `x2wincur` [![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` 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` is a tool that converts cursors from Windows format (`*.cur`, `win2xcur` is a tool that converts cursors from Windows format (`*.cur`,
`*.ani`) to Xcursor format. This allows Windows cursor themes to be used on `*.ani`) to Xcursor format. This allows Windows cursor themes to be used on
@ -9,7 +9,7 @@ hotspot and animation delay, and has an optional mode to add shadows that
replicates Windows's cursor shadow effect. replicates Windows's cursor shadow effect.
`x2wincur` is a tool that does the opposite: it converts cursors in the Xcursor `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 format to Windows format (`*.cur`, `*.ani`), allowing to use your favourite
Linux cursor themes on Windows. Linux cursor themes on Windows.
## Installation ## Installation
@ -44,3 +44,11 @@ For example, if you want to convert DMZ-White to Windows:
mkdir dmz-white/ mkdir dmz-white/
x2wincur /usr/share/icons/DMZ-White/cursors/* -o 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

4
codecov.yml Normal file
View file

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

View file

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

View file

@ -1 +1,2 @@
numpy
Wand Wand

View file

@ -7,9 +7,9 @@ with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f:
setup( setup(
name='win2xcur', name='win2xcur',
version='0.0.3', version='0.1.2',
packages=find_packages(), packages=find_packages(),
install_requires=['Wand'], install_requires=['numpy', 'Wand'],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [

View file

@ -6,13 +6,15 @@ from wand.sequence import SingleImage
class CursorImage: class CursorImage:
image: SingleImage image: SingleImage
hotspot: Tuple[int, int] hotspot: Tuple[int, int]
nominal: int
def __init__(self, image: SingleImage, hotspot: Tuple[int, int]) -> None: def __init__(self, image: SingleImage, hotspot: Tuple[int, int], nominal: int) -> None:
self.image = image self.image = image
self.hotspot = hotspot self.hotspot = hotspot
self.nominal = nominal
def __repr__(self) -> str: def __repr__(self) -> str:
return 'CursorImage(image=%r, hotspot=%r)' % (self.image, self.hotspot) return f'CursorImage(image={self.image!r}, hotspot={self.hotspot!r}, nominal={self.nominal!r})'
class CursorFrame: class CursorFrame:
@ -33,4 +35,4 @@ class CursorFrame:
return iter(self.images) return iter(self.images)
def __repr__(self) -> str: def __repr__(self) -> str:
return 'CursorFrame(images=%r, delay=%r)' % (self.images, self.delay) return f'CursorFrame(images={self.images!r}, delay={self.delay!r})'

View file

@ -7,10 +7,9 @@ from multiprocessing.pool import ThreadPool
from threading import Lock from threading import Lock
from typing import BinaryIO from typing import BinaryIO
from win2xcur import shadow from win2xcur import scale, shadow
from win2xcur.parser import open_blob from win2xcur.parser import open_blob
from win2xcur.writer import to_x11 from win2xcur.writer import to_x11
from win2xcur.writer.x11 import check_xcursorgen
def main() -> None: def main() -> None:
@ -30,15 +29,15 @@ def main() -> None:
parser.add_argument('-x', '--shadow-x', type=float, default=0.05, parser.add_argument('-x', '--shadow-x', type=float, default=0.05,
help='x-offset of shadow (as fraction of width)') help='x-offset of shadow (as fraction of width)')
parser.add_argument('-y', '--shadow-y', type=float, default=0.05, parser.add_argument('-y', '--shadow-y', type=float, default=0.05,
help='x-offset of shadow (as fraction of height)') help='y-offset of shadow (as fraction of height)')
parser.add_argument('-c', '--shadow-color', default='#000000', parser.add_argument('-c', '--shadow-color', default='#000000',
help='color of the shadow') 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() args = parser.parse_args()
print_lock = Lock() print_lock = Lock()
check_xcursorgen()
def process(file: BinaryIO) -> None: def process(file: BinaryIO) -> None:
name = file.name name = file.name
blob = file.read() blob = file.read()
@ -46,9 +45,11 @@ def main() -> None:
cursor = open_blob(blob) cursor = open_blob(blob)
except Exception: except Exception:
with print_lock: with print_lock:
print('Error occurred while processing %s:' % (name,), file=sys.stderr) print(f'Error occurred while processing {name}:', file=sys.stderr)
traceback.print_exc() traceback.print_exc()
else: else:
if args.scale:
scale.apply_to_frames(cursor.frames, scale=args.scale)
if args.shadow: if args.shadow:
shadow.apply_to_frames(cursor.frames, color=args.shadow_color, radius=args.shadow_radius, 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) sigma=args.shadow_sigma, xoffset=args.shadow_x, yoffset=args.shadow_y)

View file

@ -7,6 +7,7 @@ from multiprocessing.pool import ThreadPool
from threading import Lock from threading import Lock
from typing import BinaryIO from typing import BinaryIO
from win2xcur import scale
from win2xcur.parser import open_blob from win2xcur.parser import open_blob
from win2xcur.writer import to_smart from win2xcur.writer import to_smart
@ -17,6 +18,8 @@ def main() -> None:
help='X11 cursor files to convert (no extension)') help='X11 cursor files to convert (no extension)')
parser.add_argument('-o', '--output', '--output-dir', default=os.curdir, parser.add_argument('-o', '--output', '--output-dir', default=os.curdir,
help='Directory to store converted cursor files.') 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() args = parser.parse_args()
print_lock = Lock() print_lock = Lock()
@ -28,9 +31,11 @@ def main() -> None:
cursor = open_blob(blob) cursor = open_blob(blob)
except Exception: except Exception:
with print_lock: with print_lock:
print('Error occurred while processing %s:' % (name,), file=sys.stderr) print(f'Error occurred while processing {name}:', file=sys.stderr)
traceback.print_exc() traceback.print_exc()
else: else:
if args.scale:
scale.apply_to_frames(cursor.frames, scale=args.scale)
ext, result = to_smart(cursor.frames) ext, result = to_smart(cursor.frames)
output = os.path.join(args.output, os.path.basename(name) + ext) output = os.path.join(args.output, os.path.basename(name) + ext)
with open(output, 'wb') as f: with open(output, 'wb') as f:

View file

@ -32,7 +32,7 @@ class ANIParser(BaseParser):
signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size]) signature, size, subtype = cls.RIFF_HEADER.unpack(blob[:cls.RIFF_HEADER.size])
except struct.error: except struct.error:
return False return False
return signature == cls.SIGNATURE and size == len(blob) - 8 and subtype == cls.ANI_TYPE return signature == cls.SIGNATURE and subtype == cls.ANI_TYPE
def __init__(self, blob: bytes) -> None: def __init__(self, blob: bytes) -> None:
super().__init__(blob) super().__init__(blob)
@ -53,20 +53,20 @@ class ANIParser(BaseParser):
found += [name] found += [name]
offset += size offset += size
if offset >= len(self.blob): if offset >= len(self.blob):
raise ValueError('Expected chunk %r, found %r' % (expected, found)) raise ValueError(f'Expected chunk {expected!r}, found {found!r}')
return name, size, offset return name, size, offset
def _parse(self, offset: int) -> List[CursorFrame]: def _parse(self, offset: int) -> List[CursorFrame]:
_, size, offset = self._read_chunk(offset, expected=[self.HEADER_CHUNK]) _, size, offset = self._read_chunk(offset, expected=[self.HEADER_CHUNK])
if size != self.ANIH_HEADER.size: if size != self.ANIH_HEADER.size:
raise ValueError('Unexpected anih header size %d, expected %d' % (size, self.ANIH_HEADER.size)) raise ValueError(f'Unexpected anih header size {size}, expected {self.ANIH_HEADER.size}')
size, frame_count, step_count, width, height, bit_count, planes, display_rate, flags = self.ANIH_HEADER.unpack( 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]) self.blob[offset:offset + self.ANIH_HEADER.size])
if size != self.ANIH_HEADER.size: if size != self.ANIH_HEADER.size:
raise ValueError('Unexpected size in anih header %d, expected %d' % (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: if not flags & self.ICON_FLAG:
raise NotImplementedError('Raw BMP images not supported.') raise NotImplementedError('Raw BMP images not supported.')
@ -82,8 +82,8 @@ class ANIParser(BaseParser):
if name == self.LIST_CHUNK: if name == self.LIST_CHUNK:
list_end = offset + size list_end = offset + size
if self.blob[offset:offset + 4] != self.FRAME_TYPE: if self.blob[offset:offset + 4] != self.FRAME_TYPE:
raise ValueError('Unexpected RIFF list type: %r, expected %r' % raise ValueError(
(self.blob[offset:offset + 4], self.FRAME_TYPE)) f'Unexpected RIFF list type: {self.blob[offset:offset + 4]!r}, expected {self.FRAME_TYPE!r}')
offset += 4 offset += 4
for i in range(frame_count): for i in range(frame_count):
@ -94,16 +94,16 @@ class ANIParser(BaseParser):
offset += 1 offset += 1
if offset != list_end: if offset != list_end:
raise ValueError('Wrong RIFF list size: %r, expected %r' % (offset, list_end)) raise ValueError(f'Wrong RIFF list size: {offset}, expected {list_end}')
elif name == self.SEQ_CHUNK: elif name == self.SEQ_CHUNK:
order = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])] order = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])]
if len(order) != step_count: if len(order) != step_count:
raise ValueError('Wrong animation sequence size: %r, expected %r' % (len(order), step_count)) raise ValueError(f'Wrong animation sequence size: {len(order)}, expected {step_count}')
offset += size offset += size
elif name == self.RATE_CHUNK: elif name == self.RATE_CHUNK:
delays = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])] delays = [i for i, in self.UNSIGNED.iter_unpack(self.blob[offset:offset + size])]
if len(delays) != step_count: if len(delays) != step_count:
raise ValueError('Wrong animation rate size: %r, expected %r' % (len(delays), step_count)) raise ValueError(f'Wrong animation rate size: {len(delays)}, expected {step_count}')
offset += size offset += size
if len(order) != step_count: if len(order) != step_count:

View file

@ -22,7 +22,7 @@ class CURParser(BaseParser):
self._image = Image(blob=blob, format='cur') self._image = Image(blob=blob, format='cur')
self._hotspots = self._parse_header() self._hotspots = self._parse_header()
self.frames = [CursorFrame([ self.frames = [CursorFrame([
CursorImage(image, hotspot) for image, hotspot in zip(self._image.sequence, self._hotspots) CursorImage(image, hotspot, image.width) for image, hotspot in zip(self._image.sequence, self._hotspots)
])] ])]
def _parse_header(self) -> List[Tuple[int, int]]: def _parse_header(self) -> List[Tuple[int, int]]:

View file

@ -2,9 +2,10 @@ import struct
from collections import defaultdict from collections import defaultdict
from typing import Any, Dict, List, Tuple, cast from typing import Any, Dict, List, Tuple, cast
from wand.image import Image
from win2xcur.cursor import CursorFrame, CursorImage from win2xcur.cursor import CursorFrame, CursorImage
from win2xcur.parser.base import BaseParser from win2xcur.parser.base import BaseParser
from win2xcur.utils import image_from_pixels
class XCursorParser(BaseParser): class XCursorParser(BaseParser):
@ -31,7 +32,7 @@ class XCursorParser(BaseParser):
assert magic == self.MAGIC assert magic == self.MAGIC
if version != self.VERSION: if version != self.VERSION:
raise ValueError('Unsupported Xcursor version %r' % version) raise ValueError(f'Unsupported Xcursor version 0x{version:08x}')
offset = self.FILE_HEADER.size offset = self.FILE_HEADER.size
chunks: List[Tuple[int, int, int]] = [] chunks: List[Tuple[int, int, int]] = []
@ -48,37 +49,39 @@ class XCursorParser(BaseParser):
size, actual_type, nominal_size, version, width, height, x_offset, y_offset, delay = \ size, actual_type, nominal_size, version, width, height, x_offset, y_offset, delay = \
self._unpack(self.IMAGE_HEADER, position) self._unpack(self.IMAGE_HEADER, position)
delay /= 1000
if size != self.IMAGE_HEADER.size: if size != self.IMAGE_HEADER.size:
raise ValueError('Unexpected size: %r, expected %r' % (size, self.IMAGE_HEADER.size)) raise ValueError(f'Unexpected size: {size}, expected {self.IMAGE_HEADER.size}')
if actual_type != chunk_type: if actual_type != chunk_type:
raise ValueError('Unexpected chunk type: %r, expected %r' % (actual_type, chunk_type)) raise ValueError(f'Unexpected chunk type: {actual_type}, expected {chunk_type}')
if nominal_size != chunk_subtype: if nominal_size != chunk_subtype:
raise ValueError('Unexpected nominal size: %r, expected %r' % (nominal_size, chunk_subtype)) raise ValueError(f'Unexpected nominal size: {nominal_size}, expected {chunk_subtype}')
if width > 0x7FFF: if width > 0x7FFF:
raise ValueError('Image width too large: %r' % width) raise ValueError(f'Image width too large: {width}')
if height > 0x7FFF: if height > 0x7FFF:
raise ValueError('Image height too large: %r' % height) raise ValueError(f'Image height too large: {height}')
if x_offset > width: if x_offset > width:
raise ValueError('Hotspot x-coordinate too large: %r' % x_offset) raise ValueError(f'Hotspot x-coordinate too large: {x_offset}')
if y_offset > height: if y_offset > height:
raise ValueError('Hotspot x-coordinate too large: %r' % y_offset) raise ValueError(f'Hotspot x-coordinate too large: {y_offset}')
image_start = position + self.IMAGE_HEADER.size image_start = position + self.IMAGE_HEADER.size
image_size = width * height * 4 image_size = width * height * 4
blob = self.blob[image_start:image_start + image_size] blob = self.blob[image_start:image_start + image_size]
if len(blob) != image_size: if len(blob) != image_size:
raise ValueError('Invalid image at %d: expected %d bytes, got %d bytes' % raise ValueError(f'Invalid image at {image_start}: expected {image_size} bytes, got {len(blob)} bytes')
(image_size, image_size, len(blob)))
image = Image(width=width, height=height)
image.import_pixels(channel_map='BGRA', data=blob)
images_by_size[nominal_size].append( images_by_size[nominal_size].append(
(CursorImage(image_from_pixels(blob, width, height, 'ARGB', 'char'), (x_offset, y_offset)), delay) (CursorImage(image.sequence[0], (x_offset, y_offset), nominal_size), delay)
) )
if len(set(map(len, images_by_size.values()))) != 1: if len(set(map(len, images_by_size.values()))) != 1:

12
win2xcur/scale.py Normal file
View file

@ -0,0 +1,12 @@
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,24 +1,47 @@
from typing import List from typing import List
from wand.image import BaseImage, Image from wand.color import Color
from wand.image import BaseImage, COMPOSITE_OPERATORS, Image
from win2xcur.cursor import CursorFrame 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, def apply_to_image(image: BaseImage, *, color: str, radius: float, sigma: float, xoffset: float,
yoffset: float) -> Image: yoffset: float) -> Image:
opacity = Image(width=image.width, height=image.height, pseudo='xc:white') xoffset = round(xoffset * image.width)
opacity.composite(image.channel_images['opacity'], left=round(xoffset * image.width), yoffset = round(yoffset * image.height)
top=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.gaussian_blur(radius * image.width, sigma * image.width) opacity.gaussian_blur(radius * image.width, sigma * image.width)
opacity.negate() opacity.negate()
opacity.modulate(50) opacity.modulate(50)
shadow = Image(width=image.width, height=image.height, pseudo='xc:' + color) shadow = Image(width=new_width, height=new_height, pseudo='xc:' + color)
shadow.composite(opacity, operator='copy_opacity') shadow.composite(opacity, operator=COPY_ALPHA)
result = image.clone() result = Image(width=new_width, height=new_height, pseudo='xc:transparent')
result.composite(shadow, operator='difference') 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))
return result return result

View file

@ -1,20 +1,12 @@
import ctypes from typing import Any
from wand.api import library import numpy as np
from wand.image import Image
from wand.sequence import SingleImage
MagickImportImagePixels = library['MagickImportImagePixels']
MagickImportImagePixels.argtypes = (
ctypes.c_void_p, ctypes.c_ssize_t, ctypes.c_ssize_t, ctypes.c_size_t,
ctypes.c_size_t, ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p
)
StorageType = ('undefined', 'char', 'double', 'float',
'integer', 'long', 'quantum', 'short')
def image_from_pixels(blob: bytes, width: int, height: int, pixel_format: str, pixel_size: str) -> SingleImage: def premultiply_alpha(source: bytes) -> bytes:
image = Image(width=width, height=height) buffer: np.ndarray[Any, np.dtype[np.double]] = np.frombuffer(source, dtype=np.uint8).astype(np.double)
MagickImportImagePixels(image.wand, 0, 0, width, height, pixel_format.encode('ascii'), alpha = buffer[3::4] / 255.0
StorageType.index(pixel_size), blob) buffer[0::4] *= alpha
return image.sequence[0] buffer[1::4] *= alpha
buffer[2::4] *= alpha
return buffer.astype(np.uint8).tobytes()

View file

@ -1,54 +1,50 @@
import os from itertools import chain
import subprocess from operator import itemgetter
import sys
from tempfile import TemporaryDirectory
from typing import List from typing import List
from wand.image import Image
from win2xcur.cursor import CursorFrame from win2xcur.cursor import CursorFrame
from win2xcur.parser import XCursorParser
xcursorgen_checked = False from win2xcur.utils import premultiply_alpha
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: def to_x11(frames: List[CursorFrame]) -> bytes:
check_xcursorgen() chunks = []
counter = 0
configs = []
with TemporaryDirectory() as png_dir:
for frame in frames: for frame in frames:
for cursor in frame: for cursor in frame:
name = '%d.png' % (counter,)
hx, hy = cursor.hotspot hx, hy = cursor.hotspot
configs.append('%d %d %d %s %d' % (cursor.image.width, hx, hy, name, int(frame.delay * 1000))) 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')))
))
image = Image(image=cursor.image) header = XCursorParser.FILE_HEADER.pack(
image.save(filename=os.path.join(png_dir, name)) XCursorParser.MAGIC,
counter += 1 XCursorParser.FILE_HEADER.size,
XCursorParser.VERSION,
len(chunks),
)
output_file = os.path.join(png_dir, 'cursor') offset = XCursorParser.FILE_HEADER.size + len(chunks) * XCursorParser.TOC_CHUNK.size
process = subprocess.Popen(['xcursorgen', '-p', png_dir, '-', output_file], stdin=subprocess.PIPE, toc = []
stderr=subprocess.PIPE) for chunk_type, chunk_subtype, chunk in chunks:
toc.append(XCursorParser.TOC_CHUNK.pack(
chunk_type,
chunk_subtype,
offset,
))
offset += len(chunk)
_, error = process.communicate('\n'.join(configs).encode(sys.getfilesystemencoding())) return b''.join(chain([header], toc, map(itemgetter(2), chunks)))
if process.wait() != 0:
raise RuntimeError('xcursorgen failed: %r' % error)
with open(output_file, 'rb') as f:
result = f.read()
return result