从Windows动态指针到MacOS动态指针—— 在Windows上制作指针
蓝晶小福泥
2022年12月21日 12:11
收录于文集
共3篇

0x01 写在前面

上一篇文章​讲述了如何在拥有一台运行MacOS系统的基础上进行MacOS动态光标制作,本编专栏将作为本系列教程的最后一篇,教大家如何在Windows上将一个Windows的动态光标转化为MacOS可用的动态光标。

0x02 所需条件

  • Python脚本运行能力

0x03 cape文件格式

其实我们的思路很简单,只需要在windows上完成cape文件的组装与创建即可,那么我们使用VScode(记事本亦可)打开一个cape文件。

cape文件

发现Cape文件其实是使用XML格式作为主框架的,且使用了PropertyList作为数据结构进行组织,经过查阅,发现python的plistlib可以提供plist文件的解析与创建,以下是官网文档给定的示例:

代码块
text/x-python
自动换行
复制代码
pl = dict(
    aString = "Doodah",
    aList = ["A", "B", 12, 32.1, [1, 2, 3]],
    aFloat = 0.1,
    anInt = 728,
    aDict = dict(
        anotherString = "<hello & hi there!>",
        aThirdString = "M\xe4ssig, Ma\xdf",
        aTrueValue = True,
        aFalseValue = False,
    ),
    someData = b"<binary gunk>",
    someMoreData = b"<lots of binary gunk>" * 10,
    aDate = datetime.datetime.fromtimestamp(time.mktime(time.gmtime())),
)
with open(fileName, 'wb') as fp:
    dump(pl, fp)
复制成功

那么,我们只需要搞清楚cape文件中的dict格式即可。那么,一个很朴素的想法是,我们直接读一下那个cape文件,然后打印出来不就完事了,确实!那么我们可以写程序:

代码块
text/x-python
自动换行
复制代码
from plistlib import load
with open('test.cape', 'rb') as fp:
    pl = load(fp)
print(pl)
复制成功

我们得到以下结果(隐去了部分数据):

代码块
text/x-python
自动换行
复制代码
{
    'Author': '',
    'CapeName': '',
    'CapeVersion': 1.0,
    'Cloud': False,
    'Cursors': {
        'com.apple.coregraphics.Arrow': {
            'FrameCount': 1,
            'FrameDuration': 1.0,
            'HotSpotX': 0.0,
            'HotSpotY': 0.0,
            'PointsHigh': 32.0,
            'PointsWide': 32.0,
            'Representations': [b'']
        }
    },
    'HiDPI': False,
    'Identifier': 'local.error404.Unnamed.635739212.539397.A9D5CFB1-558B-46DD-95BB-3E8D6ED022D8.693110507.131993',
    'MinimumVersion': 2.0,
    'Version': 2.0
}
复制成功

其实我们可以很简单的将其与Mousecape中的参数一一对应:

  • Author对应Author:指针方案的作者名,会显示在指针选择界面。

  • CapeName对应Name:指针方案的名字,会显示在指针选择界面。

  • CapeVersion对应Version:指针方案的版本。

  • Cloud无对应项:猜测表示此指针方案是否来自于云端下载,建议保持False。

  • HiDPI对应Retina:勾选会在指针界面显示"SD",否则显示"HD"。

  • Identifier对应Identifier:指针方案的唯一ID,格式为“local.<Author>.<CapeName>.<时间戳,猜测>.<大写UUID>.<时间戳,猜测>”。

而Cursors里面的就是每个指针的参数了:

  • Dict的Key对应Type:指针的类型,即在什么情形下会显示此指针。

  • FrameCount对应Frames:此指针有几帧,动态指针必须设置,静态指针请保持为1。

  • FrameDuration对应Frame Duration:帧切换速率,单位为秒,动态指针必须设置,静态指针请保持为1。

  • HotSpotX对应Hot Spot的第一个数值:指针焦点位置横坐标,指,对于双箭头、四箭头类的指针必须设置,绝大多数指针保持0即可,单位为像素。

  • HotSpotY对应Hot Spot的第二个数值:指针焦点位置纵坐标,指,对于双箭头、四箭头类的指针必须设置,绝大多数指针保持0即可,单位为像素。

  • PointsHigh对应Size的第一个数值:指针图像大小高度,此项显示的大小即为实际显示的大小,单位为像素。

  • PointsWide对应Size的第二个数值:指针图像大小宽度,此项显示的大小即为实际显示的大小,单位为像素。

  • Representations对应图像框框里的图像。

那么明确了文件的结构以及参数含义,我们就可以开始制作指针了吗?事实上,我们还有一个函待解决的问题,那就是Representations里面那一堆东西究竟是什么。

首先,凭经验可以看出这一堆应该是base系列编码,那么按base64解码后,我们可以看到

base64 解码结果

根据文件头“4D 4D”,可以判断这是TIFF格式的图片。以下是Wiki对于TIFF格式的表述:

标签图像文件格式(Tagged Image File Format,简写为TIFF)是一种灵活的位图格式,主要用来存储包括照片和艺术图在内的图像。它最初由Aldus公司与微软公司一起为PostScript打印开发。TIFF与JPEG和PNG一起成为流行的高位彩色图像格式。TIFF格式在业界得到了广泛的支持,如Adobe公司的Photoshop、The GIMP Team的GIMP、Ulead PhotoImpact和Paint Shop Pro等图像处理应用、QuarkXPress和Adobe InDesign这样的桌面印刷和页面排版应用,扫描、传真、文字处理、光学字符识别和其它一些应用等都支持这种格式。如今Adobe公司从Aldus获得了印刷应用程序-PageMaker之后控制着TIFF的规范。

...

每个TIFF文件都是从指示字节序的两个字节开始的。“II”表示小端序、“MM”表示大端序。后面的两个字节表示数字42。数字42是“为了它深远的哲学意味”而选择的。42的读法取决于头两个字节所表示的字节顺序。整个文件根据所指出的字节顺序进行读取。

0x04 组装cape文件

很幸运,TIFF是pillow的可读写格式。

pillow Document

那么我们按思路可以写出转换用脚本(暂时只支持纯ANI指针方案转换):

代码块
text/x-python
自动换行
复制代码
from config import capeConfig
from plistlib import dumps,loads,dump,FMT_XML,load
from PIL import Image
import io,os,sys,base64
import logging
import time
import uuid

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__':
    capeData = {
        'Author': capeConfig['Author'],
        'CapeName': capeConfig['CapeName'],
        'CapeVersion': capeConfig['CapeVersion'],
        'Cloud': False,
        'Cursors': {},
        'HiDPI': capeConfig['HiDPI'],
        'Identifier': f"local.{capeConfig['Author']}.{capeConfig['CapeName']}.{time.time()}.{str(uuid.uuid4()).upper()}.{time.time()}",
        'MinimumVersion': 2.0,
        'Version': 2.0
    }
    for cursorType in capeConfig['Cursors'].keys():
        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],
            '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:
        dump(capeData, f, fmt=FMT_XML, sort_keys=True, skipkeys=False)
复制成功

脚本中用到的config.py内容如下:

代码块
text/x-python
自动换行
复制代码
capeConfig = {
    'Author': '小蓝蓝',
    'CapeName': '蓝蓝的测试图标',
    'CapeVersion': 1.0,
    'Cursors': {
        'com.apple.coregraphics.Arrow': {
            'FrameDuration': 0.1,
            'HotSpot': (0.0, 0.0),
            'Size': (32.0, 32.0),
            'ANIPath': "./busy.ani"
        }
    },
    'HiDPI': False
}
复制成功

运行后会在脚本同级目录生成可用的cape文件。

0x05 后记

其实我们还有最后一个问题,就是,Key如何取值,我将在https://github.com/hebtuerror404/ANI2Cape列出所有Key取值的系统原生图标样式,如有需要可参考取值。

如果本系列专栏能为您带来帮助,请一键三连支持一下~

com.apple.cursor.10