From d8a9ba071390fd0c356a035e1cdd470a4dea5ef5 Mon Sep 17 00:00:00 2001 From: Mike L Date: Tue, 1 Oct 2024 03:14:01 +0200 Subject: [PATCH] Features, packaging + sample config, cf. README.md --- README.md | 13 ++++ ani2cape.py | 174 +++++++++++++++++++++++++++++++--------------------- config.py | 113 ++++++++++++++++++++++++++++++++-- setup.py | 13 ++++ 4 files changed, 237 insertions(+), 76 deletions(-) create mode 100644 setup.py diff --git a/README.md b/README.md index 0f90376..8a962e2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ +# Forked ANI2Cape + +- 添加了一个完整的[config.py](./config.py)范例 +- 添加了对普通静态.cur文件的支持(部分.cur是直接用.ico重命名得到的,在此特别支持) +- HiDPI打开时(范例中默认打开),将源文件自动视为两倍大小版本导入,并同时生成普通大小版本 +- 添加了自动计算长宽的功能 + a. 长和宽均为-1,则直接从原图取得长宽数值 + b. 长、宽二者之一为-1,则从给定数值的边长计算出缩放倍率并等比缩放 + +注: +- `ani2cape.py`不接受参数,直接运行即读取同目录下`config.py`内容,其中`Cursors`子项目`Path`属性使用相对路径时基于当前切换到的目录(而不是程序目录) +- 建议先创建并启用venv后,用`pip install -e .`安装,编辑`config.py`内容后,再切换到包含指针的目录直接运行`ani2cape`命令 + # ANI2Cape A tool that can convert Windows Animated Cursors (*.ani) to GIF/Pillow Images/Cape format diff --git a/ani2cape.py b/ani2cape.py index 16a88a1..27527d5 100644 --- a/ani2cape.py +++ b/ani2cape.py @@ -1,56 +1,75 @@ -from config import capeConfig -from plistlib import dumps,loads,dump,FMT_XML,load -from PIL import Image -import io,os,sys,base64 +import io import logging import time import uuid +from PIL import Image -logging.basicConfig(format='%(asctime)s - %(pathname)s[line:%(lineno)d] - %(levelname)s: %(message)s',level=logging.INFO) +logging.basicConfig(format='%(asctime)s - %(pathname)s[line:%(lineno)d] - %(levelname)s: %(message)s', level=logging.INFO) -def analyzeANIFile(filePath): - with open(filePath,'rb') as f: - if f.read(4) != b'RIFF': - return {"code":-1,"msg":"File is not a ANI File!"} - logging.debug('文件头检查完成!') - fileSize = int.from_bytes(f.read(4), byteorder='little', signed=False) - # if os.path.getsize(filePath) != fileSize: - # return {"code":-2,"msg":"File is damaged!"} - logging.debug('文件长度检查完成!') - if f.read(4) != b'ACON': - return {"code":-1,"msg":"File is not a ANI File!"} - logging.debug('魔数检查完成!') - frameRate = (1/60)*1000 - while(True): - chunkName = f.read(4) - if chunkName == b'LIST': - break - chunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False) - if chunkName.lower() == b'rate': - logging.debug('发现自定义速率!') - frameRate = frameRate * int.from_bytes(f.read(4), byteorder='little', signed=False) - logging.warning('发现自定义速率!由于GIF限制,将取第一帧与第二帧的速率作为整体速率!') - f.read(chunkSize - 4) - else: - logging.debug('发现自定义Chunk!') - f.read(chunkSize) - listChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False) - if f.read(4) != b'fram': - return {"code":-3,"msg":"File not a ANI File!(No Frames)"} - logging.debug('frame头检查完成!') - frameList = [] - nowSize = 4 - while(nowSize < listChunkSize): - if f.read(4) != b'icon': - return {"code":-4,"msg":"File not a ANI File!(Other Kind Frames)"} - nowSize += 4 - subChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False) - nowSize += 4 - frameList.append(f.read(subChunkSize)) - nowSize += subChunkSize - return {"code":0,"msg":frameList,"frameRate":frameRate} -if __name__ == '__main__': +def scaleImage(img, scale): + if img is None: + return + return img.resize((img.width * scale, img.height * scale)) + + +def readCUR(f, width=-1.0, height=-1.0): + frameImage = Image.open(f, formats=['cur', 'ico']).convert('RGBA') + if (width, height) == (-1.0, -1.0): + return frameImage, (float(frameImage.width), float(frameImage.height)) + if -1 in (width, height): + width = height / frameImage.height * frameImage.width if width == -1 else width + height = width / frameImage.width * frameImage.height if height == -1 else height + return frameImage.resize((int(width), int(height))), (width, height) + + +def analyzeANI(f): + if f.read(4) != b'RIFF': + return {'code': -1, 'msg': 'File is not a ANI File!'} + logging.debug('文件头检查完成!') + fileSize = int.from_bytes(f.read(4), byteorder='little', signed=False) + # if os.path.getsize(filePath) != fileSize: + # return {'code':-2,'msg':'File is damaged!'} + logging.debug('文件长度检查完成!') + if f.read(4) != b'ACON': + return {'code': -1, 'msg': 'File is not a ANI File!'} + logging.debug('魔数检查完成!') + frameRate = (1/60)*1000 + while (True): + chunkName = f.read(4) + if chunkName == b'LIST': + break + chunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False) + if chunkName.lower() == b'rate': + logging.debug('发现自定义速率!') + frameRate = frameRate * int.from_bytes(f.read(4), byteorder='little', signed=False) + logging.warning('发现自定义速率!由于GIF限制,将取第一帧与第二帧的速率作为整体速率!') + f.read(chunkSize - 4) + else: + logging.debug('发现自定义Chunk!') + f.read(chunkSize) + listChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False) + if f.read(4) != b'fram': + return {'code': -3, 'msg': 'File not a ANI File!(No Frames)'} + logging.debug('frame头检查完成!') + frameList = [] + nowSize = 4 + while (nowSize < listChunkSize): + if f.read(4) != b'icon': + return {'code': -4, 'msg': 'File not a ANI File!(Other Kind Frames)'} + nowSize += 4 + subChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False) + nowSize += 4 + frameList.append(f.read(subChunkSize)) + nowSize += subChunkSize + return {'code': 0, 'msg': frameList, 'frameRate': frameRate} + + +def main(): + from config import capeConfig + uniqueId = (f'local.{capeConfig['Author'] or 'unknown'}' + f'.{capeConfig['CapeName'] or 'untitled'}' + f'.{time.time()}.{str(uuid.uuid4()).upper()}') capeData = { 'Author': capeConfig['Author'], 'CapeName': capeConfig['CapeName'], @@ -58,35 +77,48 @@ if __name__ == '__main__': 'Cloud': False, 'Cursors': {}, 'HiDPI': capeConfig['HiDPI'], - 'Identifier': f"local.{capeConfig['Author']}.{capeConfig['CapeName']}.{time.time()}.{str(uuid.uuid4()).upper()}.{time.time()}", + 'Identifier': capeConfig['Identifier'] or uniqueId, 'MinimumVersion': 2.0, 'Version': 2.0 } - for cursorType in capeConfig['Cursors'].keys(): + for cursorType, cursorConfig in capeConfig['Cursors'].items(): cursorSetting = { 'FrameCount': 1, - 'FrameDuration': capeConfig['Cursors'][cursorType]['FrameDuration'], - 'HotSpotX': capeConfig['Cursors'][cursorType]['HotSpot'][0], - 'HotSpotY': capeConfig['Cursors'][cursorType]['HotSpot'][1], - 'PointsHigh': capeConfig['Cursors'][cursorType]['Size'][0], - 'PointsWide': capeConfig['Cursors'][cursorType]['Size'][1], + 'FrameDuration': cursorConfig['FrameDuration'], + 'HotSpotX': cursorConfig['HotSpot'][0], + 'HotSpotY': cursorConfig['HotSpot'][1], 'Representations': [] } - res = analyzeANIFile(capeConfig['Cursors'][cursorType]['ANIPath']) - if res["code"] == 0: - logging.info('ANI文件分析完成,帧提取完成!') - cursorSetting['FrameCount'] = len(res["msg"]) - spriteSheet = Image.new("RGBA", (int(cursorSetting['PointsHigh']), int(cursorSetting['PointsWide'] * len(res["msg"])))) - for frameIndex in range(len(res["msg"])): - frameImage = Image.open(io.BytesIO(res["msg"][frameIndex]),formats=['cur']).convert('RGBA') - extracted_frame = frameImage.resize((int(cursorSetting['PointsHigh']), int(cursorSetting['PointsWide']))) - position = (0, int(cursorSetting['PointsHigh'] * frameIndex)) - spriteSheet.paste(extracted_frame, position) - - byteBuffer = io.BytesIO() - spriteSheet.save(byteBuffer,format="TIFF") - cursorSetting['Representations'].append(byteBuffer.getvalue()) - capeData['Cursors'][cursorType] = cursorSetting - - with open(f"{capeData['Identifier']}.cape",'wb') as f: + hidpiRatio = 2 if capeConfig['HiDPI'] else 1 + width, height = cursorConfig.get('Size', (-1.0, -1.0)) + + with open(cursorConfig['Path'], 'rb') as f: + spriteSheet = None + if (res := analyzeANI(f))['code'] == 0: + logging.info('ANI文件分析完成,帧提取完成!') + cursorSetting['FrameCount'] = len(res['msg']) + for frameIndex in range(len(res['msg'])): + b = io.BytesIO(res['msg'][frameIndex]) + frame, (width, height) = readCUR(b, width, height) + position = (0, int(width * frameIndex)) + if frameIndex == 0: + spriteSheet = Image.new('RGBA', (int(width), int(height) * len(res['msg']))) + spriteSheet.paste(frame, position) + else: + logging.info('尝试作为CUR读入') + spriteSheet, (width, height) = readCUR(f, width, height) + logging.info(f'目标尺寸:{width}x{height}@{hidpiRatio}x') + cursorSetting['PointsHigh'], cursorSetting['PointsWide'] = width, height + for scale in (1, 2) if capeConfig['HiDPI'] else (1,): + byteBuffer = io.BytesIO() + scaleImage(spriteSheet, scale).save(byteBuffer, format='tiff', compression='tiff_lzw') + cursorSetting['Representations'].append(byteBuffer.getvalue()) + capeData['Cursors'][cursorType] = cursorSetting + + from plistlib import dump, FMT_XML + with open(f'{capeData['Identifier']}.cape', 'wb') as f: dump(capeData, f, fmt=FMT_XML, sort_keys=True, skipkeys=False) + + +if __name__ == '__main__': + main() diff --git a/config.py b/config.py index 7881f51..14a5eea 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,117 @@ capeConfig = { - 'Author': '小蓝蓝', - 'CapeName': '蓝蓝的测试图标', + 'Author': '作者名', + 'CapeName': '鼠标指针', 'CapeVersion': 1.0, + 'Identifier': 'com.mihuashi.authornickname.cursors', 'Cursors': { 'com.apple.coregraphics.Arrow': { + 'FrameDuration': 0.1, + 'HotSpot': (0.0, 0.0), + 'Size': (32.0, -1.0), + 'Path': "./Normal.ani" + }, + 'com.apple.coregraphics.Move': { 'FrameDuration': 0.1, 'HotSpot': (0.0, 0.0), 'Size': (32.0, 32.0), - 'ANIPath': "./busy.ani" - } + 'Path': "./Normal.ani" + }, + 'com.apple.cursor.4': { + 'FrameDuration': 0.1, + 'HotSpot': (11.0, 11.0), + 'Size': (32.0, 32.0), + 'Path': "./Busy.ani" + }, + 'com.apple.cursor.34': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Diagonal1.ani" + }, + 'com.apple.cursor.30': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Diagonal2.ani" + }, + 'com.apple.cursor.40': { + 'FrameDuration': 0.1, + 'HotSpot': (0.0, 0.0), + 'Size': (32.0, 32.0), + 'Path': "./Help.ani" + }, + 'com.apple.cursor.19': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Horizontal.ani" + }, + 'com.apple.cursor.28': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Horizontal.ani" + }, + 'com.apple.cursor.2': { + 'FrameDuration': 0.1, + 'HotSpot': (0.0, 0.0), + 'Size': (32.0, 32.0), + 'Path': "./Link.ani" + }, + 'com.apple.cursor.13': { + 'FrameDuration': 0.1, + 'HotSpot': (0.0, 0.0), + 'Size': (32.0, 32.0), + 'Path': "./Link.ani" + }, + 'com.apple.cursor.39': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Move.ani" + }, + 'com.apple.cursor.5': { + 'FrameDuration': 0.1, + 'HotSpot': (7.0, 7.0), + 'Size': (32.0, 32.0), + 'Path': "./Precision.ani" + }, + 'com.apple.cursor.20': { + 'FrameDuration': 0.1, + 'HotSpot': (7.0, 7.0), + 'Size': (32.0, 32.0), + 'Path': "./Precision.ani" + }, + 'com.apple.coregraphics.IBeam': { + 'FrameDuration': 0.1, + 'HotSpot': (5.0, 7.0), + 'Size': (32.0, 32.0), + 'Path': "./Text.ani" + }, + 'com.apple.cursor.23': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Vertical.ani" + }, + 'com.apple.cursor.32': { + 'FrameDuration': 0.1, + 'HotSpot': (15.0, 15.0), + 'Size': (32.0, 32.0), + 'Path': "./Vertical.ani" + }, + 'com.apple.coregraphics.Wait': { + 'FrameDuration': 0.1, + 'HotSpot': (6.0, 6.0), + 'Size': (32.0, 32.0), + 'Path': "./Working.ani" + }, + 'com.apple.cursor.3': { + 'FrameDuration': 0.1, + 'HotSpot': (8.0, 8.0), + 'Size': (32.0, 32.0), + 'Path': "./Unavailable.ani" + }, }, - 'HiDPI': False + 'HiDPI': True } diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bd9ff3b --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup + +setup( + name='ani2cape', + version='0.0.2', + install_requires=['pillow>=10.0'], + py_modules=['ani2cape', 'config'], + entry_points={ + 'console_scripts': [ + 'ani2cape = ani2cape:main', + ] + } +)