mirror of
https://github.com/quantum5/win2xcur.git
synced 2025-04-24 02:01:57 -04:00
Initial commit
This commit is contained in:
commit
b1cd2f0539
145
.gitignore
vendored
Normal file
145
.gitignore
vendored
Normal file
|
@ -0,0 +1,145 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
.idea
|
||||
|
||||
# For testing
|
||||
input/
|
||||
output/
|
17
setup.py
Normal file
17
setup.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='win2xcur',
|
||||
version='0.3.1',
|
||||
packages=['win2xcur'],
|
||||
install_requires=['Wand'],
|
||||
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'win2xcur = win2xcur.main:main',
|
||||
],
|
||||
},
|
||||
|
||||
author='quantum',
|
||||
author_email='quantum2048@gmail.com',
|
||||
)
|
0
win2xcur/__init__.py
Normal file
0
win2xcur/__init__.py
Normal file
30
win2xcur/cursor.py
Normal file
30
win2xcur/cursor.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from typing import Iterator, List, Tuple
|
||||
|
||||
from wand.sequence import SingleImage
|
||||
|
||||
|
||||
class CursorImage:
|
||||
def __init__(self, image: SingleImage, hotspot: Tuple[int, int]) -> None:
|
||||
self.image = image
|
||||
self.hotspot = hotspot
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return 'CursorImage(image=%r, hotspot=%r)' % (self.image, self.hotspot)
|
||||
|
||||
|
||||
class CursorFrame:
|
||||
def __init__(self, images: List[CursorImage], delay=0) -> None:
|
||||
self.images = images
|
||||
self.delay = delay
|
||||
|
||||
def __getitem__(self, item) -> CursorImage:
|
||||
return self.images[item]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.images)
|
||||
|
||||
def __iter__(self) -> Iterator[CursorImage]:
|
||||
return iter(self.images)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return 'CursorFrame(images=%r, delay=%r)' % (self.images, self.delay)
|
37
win2xcur/main.py
Normal file
37
win2xcur/main.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from win2xcur.parser import open_blob
|
||||
from win2xcur.writer.x11 import check_xcursorgen, to_x11
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description='Converts Windows cursors to X11 cursors.')
|
||||
parser.add_argument('files', type=argparse.FileType('rb'), nargs='+',
|
||||
help='Windows cursor files to convert (*.cur, *.ani)')
|
||||
parser.add_argument('-o', '--output', '--output-dir', default=os.curdir,
|
||||
help='Directory to store converted cursor files.')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
check_xcursorgen()
|
||||
|
||||
for file in args.files:
|
||||
name = file.name
|
||||
blob = file.read()
|
||||
try:
|
||||
cursor = open_blob(blob)
|
||||
except Exception:
|
||||
print('Error occurred while processing %s:' % (name,), file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
else:
|
||||
result = to_x11(cursor.frames)
|
||||
output = os.path.join(args.output, os.path.splitext(os.path.basename(name))[0])
|
||||
with open(output, 'wb') as f:
|
||||
f.write(result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
11
win2xcur/parser/__init__.py
Normal file
11
win2xcur/parser/__init__.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from win2xcur.parser.ani import ANIParser
|
||||
from win2xcur.parser.cur import CURParser
|
||||
|
||||
PARSERS = [CURParser, ANIParser]
|
||||
|
||||
|
||||
def open_blob(blob):
|
||||
for parser in PARSERS:
|
||||
if parser.can_parse(blob):
|
||||
return parser(blob)
|
||||
raise ValueError('Unsupported file format')
|
86
win2xcur/parser/ani.py
Normal file
86
win2xcur/parser/ani.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
import struct
|
||||
from copy import copy
|
||||
|
||||
from win2xcur.parser.cur import CURParser
|
||||
|
||||
|
||||
class ANIParser:
|
||||
SIGNATURE = b'RIFF'
|
||||
ANI_TYPE = b'ACON'
|
||||
FRAME_TYPE = b'fram'
|
||||
RIFF_HEADER = struct.Struct('<4sI4s')
|
||||
CHUNK_HEADER = struct.Struct('<4sI')
|
||||
ANIH_HEADER = struct.Struct('<IIIIIIIII')
|
||||
UNSIGNED = struct.Struct('<I')
|
||||
SEQUENCE_FLAG = 0x2
|
||||
ICON_FLAG = 0x1
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
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, offset):
|
||||
return struct_cls.unpack(self.blob[offset:offset + struct_cls.size])
|
||||
|
||||
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):
|
||||
size, offset = self._read_chunk(offset, expected=[b'anih'])
|
||||
|
||||
if size != 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 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 = []
|
||||
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))
|
||||
|
||||
for frame, delay in zip(sequence, delays):
|
||||
frame.delay = delay / 60
|
||||
|
||||
return sequence
|
39
win2xcur/parser/cur.py
Normal file
39
win2xcur/parser/cur.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
import struct
|
||||
|
||||
from wand.image import Image
|
||||
|
||||
from win2xcur.cursor import CursorFrame, CursorImage
|
||||
|
||||
|
||||
class CURParser:
|
||||
MAGIC = b'\0\0\02\0'
|
||||
ICON_DIR = struct.Struct('<HHH')
|
||||
ICON_DIR_ENTRY = struct.Struct('<BBBBHHII')
|
||||
|
||||
@classmethod
|
||||
def can_parse(cls, blob):
|
||||
return blob[:len(cls.MAGIC)] == cls.MAGIC
|
||||
|
||||
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) for image, hotspot in zip(self._image.sequence, self._hotspots)
|
||||
])]
|
||||
|
||||
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 == 2
|
||||
assert image_count == len(self._image.sequence)
|
||||
|
||||
offset = self.ICON_DIR.size
|
||||
hotspots = []
|
||||
for i in range(image_count):
|
||||
width, height, palette, reserved, hx, hy, size, file_offset = self.ICON_DIR_ENTRY.unpack(
|
||||
self.blob[offset:offset + self.ICON_DIR_ENTRY.size])
|
||||
hotspots.append((hx, hy))
|
||||
offset += self.ICON_DIR_ENTRY.size
|
||||
|
||||
return hotspots
|
5
win2xcur/writer/__init__.py
Normal file
5
win2xcur/writer/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from win2xcur.writer.x11 import to_x11
|
||||
|
||||
CONVERTERS = {
|
||||
'x11': (to_x11, ''),
|
||||
}
|
49
win2xcur/writer/x11.py
Normal file
49
win2xcur/writer/x11.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import List
|
||||
|
||||
from wand.image import Image
|
||||
|
||||
from win2xcur.cursor import CursorFrame
|
||||
|
||||
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:
|
||||
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
|
||||
configs.append('%d %d %d %s %d' % (cursor.image.width, hx, hy, name, int(frame.delay * 1000)))
|
||||
|
||||
image = Image(image=cursor.image)
|
||||
image.save(filename=os.path.join(png_dir, name))
|
||||
counter += 1
|
||||
|
||||
process = subprocess.Popen(['xcursorgen', '-p', png_dir], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
result, error = process.communicate('\n'.join(configs).encode(sys.getfilesystemencoding()))
|
||||
|
||||
if error:
|
||||
raise RuntimeError('xcursorgen failed: %r' % error)
|
||||
|
||||
return result
|
Loading…
Reference in a new issue