Initial commit

This commit is contained in:
Quantum 2020-09-26 18:14:55 -04:00
commit b1cd2f0539
11 changed files with 421 additions and 0 deletions

145
.gitignore vendored Normal file
View 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/

2
mypy.ini Normal file
View file

@ -0,0 +1,2 @@
[mypy]
ignore_missing_imports = True

17
setup.py Normal file
View 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
View file

30
win2xcur/cursor.py Normal file
View 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
View 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()

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

View file

@ -0,0 +1,5 @@
from win2xcur.writer.x11 import to_x11
CONVERTERS = {
'x11': (to_x11, ''),
}

49
win2xcur/writer/x11.py Normal file
View 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