Browse Source

Features, packaging + sample config, cf. README.md

Mike L 2 months ago
parent
commit
d8a9ba0713
4 changed files with 237 additions and 76 deletions
  1. 13 0
      README.md
  2. 103 71
      ani2cape.py
  3. 108 5
      config.py
  4. 13 0
      setup.py

+ 13 - 0
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
 

+ 103 - 71
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()

+ 108 - 5
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),
+            '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),
-            'ANIPath': "./busy.ani"
-        }
+            'Path': "./Unavailable.ani"
+        },
     },
-    'HiDPI': False
+    'HiDPI': True
 }

+ 13 - 0
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',
+        ]
+    }
+)