上一篇文章讲述了如何在拥有一台运行MacOS系统的基础上进行MacOS动态光标制作,本编专栏将作为本系列教程的最后一篇,教大家如何在Windows上将一个Windows的动态光标转化为MacOS可用的动态光标。
Python脚本运行能力
其实我们的思路很简单,只需要在windows上完成cape文件的组装与创建即可,那么我们使用VScode(记事本亦可)打开一个cape文件。

cape文件
发现Cape文件其实是使用XML格式作为主框架的,且使用了PropertyList作为数据结构进行组织,经过查阅,发现python的plistlib可以提供plist文件的解析与创建,以下是官网文档给定的示例:
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文件,然后打印出来不就完事了,确实!那么我们可以写程序:
from plistlib import load
with open('test.cape', 'rb') as fp:
pl = load(fp)
print(pl) 我们得到以下结果(隐去了部分数据):
{
'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的读法取决于头两个字节所表示的字节顺序。整个文件根据所指出的字节顺序进行读取。
很幸运,TIFF是pillow的可读写格式。

pillow Document
那么我们按思路可以写出转换用脚本(暂时只支持纯ANI指针方案转换):
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内容如下:
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文件。
其实我们还有最后一个问题,就是,Key如何取值,我将在https://github.com/hebtuerror404/ANI2Cape列出所有Key取值的系统原生图标样式,如有需要可参考取值。
如果本系列专栏能为您带来帮助,请一键三连支持一下~
com.apple.cursor.10