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.3" have entirely different histories.
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
|
@ -9,30 +9,22 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ 3.7, 3.8, 3.9, '3.10' ]
|
python-version: [ 3.6, 3.7, 3.8, 3.9.0-alpha - 3.9 ]
|
||||||
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 dmz-cursor-theme
|
sudo apt-get install x11-apps 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
|
||||||
|
|
12
README.md
12
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` and `x2wincur` [](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`,
|
`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,11 +44,3 @@ 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
|
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
coverage:
|
|
||||||
status:
|
|
||||||
project: off
|
|
||||||
patch: off
|
|
1
mypy.ini
1
mypy.ini
|
@ -1,4 +1,3 @@
|
||||||
[mypy]
|
[mypy]
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
strict = true
|
strict = true
|
||||||
plugins = numpy.typing.mypy_plugin
|
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
numpy
|
|
||||||
Wand
|
Wand
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -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.1.2',
|
version='0.0.3',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
install_requires=['numpy', 'Wand'],
|
install_requires=['Wand'],
|
||||||
|
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
|
|
|
@ -6,15 +6,13 @@ 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], nominal: int) -> None:
|
def __init__(self, image: SingleImage, hotspot: Tuple[int, 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 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:
|
class CursorFrame:
|
||||||
|
@ -35,4 +33,4 @@ class CursorFrame:
|
||||||
return iter(self.images)
|
return iter(self.images)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
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)
|
||||||
|
|
|
@ -7,9 +7,10 @@ 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, shadow
|
from win2xcur import 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:
|
||||||
|
@ -29,15 +30,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='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',
|
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()
|
||||||
|
@ -45,11 +46,9 @@ def main() -> None:
|
||||||
cursor = open_blob(blob)
|
cursor = open_blob(blob)
|
||||||
except Exception:
|
except Exception:
|
||||||
with print_lock:
|
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()
|
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)
|
||||||
|
|
|
@ -7,7 +7,6 @@ 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
|
||||||
|
|
||||||
|
@ -18,8 +17,6 @@ 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()
|
||||||
|
@ -31,11 +28,9 @@ def main() -> None:
|
||||||
cursor = open_blob(blob)
|
cursor = open_blob(blob)
|
||||||
except Exception:
|
except Exception:
|
||||||
with print_lock:
|
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()
|
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:
|
||||||
|
|
|
@ -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 subtype == cls.ANI_TYPE
|
return signature == cls.SIGNATURE and size == len(blob) - 8 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(f'Expected chunk {expected!r}, found {found!r}')
|
raise ValueError('Expected chunk %r, found %r' % (expected, found))
|
||||||
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(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(
|
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(f'Unexpected size in anih header {size}, expected {self.ANIH_HEADER.size}')
|
raise ValueError('Unexpected size in anih header %d, expected %d' % (size, 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(
|
raise ValueError('Unexpected RIFF list type: %r, expected %r' %
|
||||||
f'Unexpected RIFF list type: {self.blob[offset:offset + 4]!r}, expected {self.FRAME_TYPE!r}')
|
(self.blob[offset:offset + 4], self.FRAME_TYPE))
|
||||||
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(f'Wrong RIFF list size: {offset}, expected {list_end}')
|
raise ValueError('Wrong RIFF list size: %r, expected %r' % (offset, 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(f'Wrong animation sequence size: {len(order)}, expected {step_count}')
|
raise ValueError('Wrong animation sequence size: %r, expected %r' % (len(order), 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(f'Wrong animation rate size: {len(delays)}, expected {step_count}')
|
raise ValueError('Wrong animation rate size: %r, expected %r' % (len(delays), step_count))
|
||||||
offset += size
|
offset += size
|
||||||
|
|
||||||
if len(order) != step_count:
|
if len(order) != step_count:
|
||||||
|
|
|
@ -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, 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) -> List[Tuple[int, int]]:
|
||||||
|
|
|
@ -2,10 +2,9 @@ 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):
|
||||||
|
@ -32,7 +31,7 @@ class XCursorParser(BaseParser):
|
||||||
assert magic == self.MAGIC
|
assert magic == self.MAGIC
|
||||||
|
|
||||||
if version != self.VERSION:
|
if version != self.VERSION:
|
||||||
raise ValueError(f'Unsupported Xcursor version 0x{version:08x}')
|
raise ValueError('Unsupported Xcursor version %r' % version)
|
||||||
|
|
||||||
offset = self.FILE_HEADER.size
|
offset = self.FILE_HEADER.size
|
||||||
chunks: List[Tuple[int, int, int]] = []
|
chunks: List[Tuple[int, int, int]] = []
|
||||||
|
@ -49,39 +48,37 @@ 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(f'Unexpected size: {size}, expected {self.IMAGE_HEADER.size}')
|
raise ValueError('Unexpected size: %r, expected %r' % (size, self.IMAGE_HEADER.size))
|
||||||
|
|
||||||
if actual_type != chunk_type:
|
if actual_type != chunk_type:
|
||||||
raise ValueError(f'Unexpected chunk type: {actual_type}, expected {chunk_type}')
|
raise ValueError('Unexpected chunk type: %r, expected %r' % (actual_type, chunk_type))
|
||||||
|
|
||||||
if nominal_size != chunk_subtype:
|
if nominal_size != chunk_subtype:
|
||||||
raise ValueError(f'Unexpected nominal size: {nominal_size}, expected {chunk_subtype}')
|
raise ValueError('Unexpected nominal size: %r, expected %r' % (nominal_size, chunk_subtype))
|
||||||
|
|
||||||
if width > 0x7FFF:
|
if width > 0x7FFF:
|
||||||
raise ValueError(f'Image width too large: {width}')
|
raise ValueError('Image width too large: %r' % width)
|
||||||
|
|
||||||
if height > 0x7FFF:
|
if height > 0x7FFF:
|
||||||
raise ValueError(f'Image height too large: {height}')
|
raise ValueError('Image height too large: %r' % height)
|
||||||
|
|
||||||
if x_offset > width:
|
if x_offset > width:
|
||||||
raise ValueError(f'Hotspot x-coordinate too large: {x_offset}')
|
raise ValueError('Hotspot x-coordinate too large: %r' % x_offset)
|
||||||
|
|
||||||
if y_offset > height:
|
if y_offset > height:
|
||||||
raise ValueError(f'Hotspot x-coordinate too large: {y_offset}')
|
raise ValueError('Hotspot x-coordinate too large: %r' % 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(f'Invalid image at {image_start}: expected {image_size} bytes, got {len(blob)} bytes')
|
raise ValueError('Invalid image at %d: expected %d bytes, got %d 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.sequence[0], (x_offset, y_offset), nominal_size), delay)
|
(CursorImage(image_from_pixels(blob, width, height, 'ARGB', 'char'), (x_offset, y_offset)), delay)
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(set(map(len, images_by_size.values()))) != 1:
|
if len(set(map(len, images_by_size.values()))) != 1:
|
||||||
|
|
|
@ -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,47 +1,24 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from wand.color import Color
|
from wand.image import BaseImage, Image
|
||||||
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:
|
||||||
xoffset = round(xoffset * image.width)
|
opacity = Image(width=image.width, height=image.height, pseudo='xc:white')
|
||||||
yoffset = round(yoffset * image.height)
|
opacity.composite(image.channel_images['opacity'], left=round(xoffset * image.width),
|
||||||
new_width = image.width + 3 * xoffset
|
top=round(yoffset * image.height))
|
||||||
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=new_width, height=new_height, pseudo='xc:' + color)
|
shadow = Image(width=image.width, height=image.height, pseudo='xc:' + color)
|
||||||
shadow.composite(opacity, operator=COPY_ALPHA)
|
shadow.composite(opacity, operator='copy_opacity')
|
||||||
|
|
||||||
result = Image(width=new_width, height=new_height, pseudo='xc:transparent')
|
result = image.clone()
|
||||||
result.composite(shadow)
|
result.composite(shadow, operator='difference')
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,20 @@
|
||||||
from typing import Any
|
import ctypes
|
||||||
|
|
||||||
import numpy as np
|
from wand.api import library
|
||||||
|
from wand.image import Image
|
||||||
|
from wand.sequence import SingleImage
|
||||||
|
|
||||||
|
MagickImportImagePixels = library['MagickImportImagePixels']
|
||||||
|
MagickImportImagePixels.argtypes = (
|
||||||
|
ctypes.c_void_p, ctypes.c_ssize_t, ctypes.c_ssize_t, ctypes.c_size_t,
|
||||||
|
ctypes.c_size_t, ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p
|
||||||
|
)
|
||||||
|
StorageType = ('undefined', 'char', 'double', 'float',
|
||||||
|
'integer', 'long', 'quantum', 'short')
|
||||||
|
|
||||||
|
|
||||||
def premultiply_alpha(source: bytes) -> bytes:
|
def image_from_pixels(blob: bytes, width: int, height: int, pixel_format: str, pixel_size: str) -> SingleImage:
|
||||||
buffer: np.ndarray[Any, np.dtype[np.double]] = np.frombuffer(source, dtype=np.uint8).astype(np.double)
|
image = Image(width=width, height=height)
|
||||||
alpha = buffer[3::4] / 255.0
|
MagickImportImagePixels(image.wand, 0, 0, width, height, pixel_format.encode('ascii'),
|
||||||
buffer[0::4] *= alpha
|
StorageType.index(pixel_size), blob)
|
||||||
buffer[1::4] *= alpha
|
return image.sequence[0]
|
||||||
buffer[2::4] *= alpha
|
|
||||||
return buffer.astype(np.uint8).tobytes()
|
|
||||||
|
|
|
@ -1,50 +1,54 @@
|
||||||
from itertools import chain
|
import os
|
||||||
from operator import itemgetter
|
import subprocess
|
||||||
|
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
|
|
||||||
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:
|
def to_x11(frames: List[CursorFrame]) -> bytes:
|
||||||
chunks = []
|
check_xcursorgen()
|
||||||
|
|
||||||
for frame in frames:
|
counter = 0
|
||||||
for cursor in frame:
|
configs = []
|
||||||
hx, hy = cursor.hotspot
|
with TemporaryDirectory() as png_dir:
|
||||||
header = XCursorParser.IMAGE_HEADER.pack(
|
for frame in frames:
|
||||||
XCursorParser.IMAGE_HEADER.size,
|
for cursor in frame:
|
||||||
XCursorParser.CHUNK_IMAGE,
|
name = '%d.png' % (counter,)
|
||||||
cursor.nominal,
|
hx, hy = cursor.hotspot
|
||||||
1,
|
configs.append('%d %d %d %s %d' % (cursor.image.width, hx, hy, name, int(frame.delay * 1000)))
|
||||||
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')))
|
|
||||||
))
|
|
||||||
|
|
||||||
header = XCursorParser.FILE_HEADER.pack(
|
image = Image(image=cursor.image)
|
||||||
XCursorParser.MAGIC,
|
image.save(filename=os.path.join(png_dir, name))
|
||||||
XCursorParser.FILE_HEADER.size,
|
counter += 1
|
||||||
XCursorParser.VERSION,
|
|
||||||
len(chunks),
|
|
||||||
)
|
|
||||||
|
|
||||||
offset = XCursorParser.FILE_HEADER.size + len(chunks) * XCursorParser.TOC_CHUNK.size
|
output_file = os.path.join(png_dir, 'cursor')
|
||||||
toc = []
|
process = subprocess.Popen(['xcursorgen', '-p', png_dir, '-', output_file], stdin=subprocess.PIPE,
|
||||||
for chunk_type, chunk_subtype, chunk in chunks:
|
stderr=subprocess.PIPE)
|
||||||
toc.append(XCursorParser.TOC_CHUNK.pack(
|
|
||||||
chunk_type,
|
|
||||||
chunk_subtype,
|
|
||||||
offset,
|
|
||||||
))
|
|
||||||
offset += len(chunk)
|
|
||||||
|
|
||||||
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