容器结构
整体布局
偏移 0
┌────────────────────────────────────────────────┐
│ MFP Header(固定 32 字节,magic = "MFP\x01") │
├────────────────────────────────────────────────┤
│ Local File Header + manifest.json(deflate) │ <- manifestOffset 指向这里
├────────────────────────────────────────────────┤
│ Local File Header + fonts/<sha256>.ttf(store) │
├────────────────────────────────────────────────┤
│ Central Directory -> ZIP64 EOCD -> Locator -> EOCD │
├────────────────────────────────────────────────┤
│ 恢复记录(可选,变长) │
├────────────────────────────────────────────────┤
│ MFP Footer Descriptor(固定 24 字节) │
└────────────────────────────────────────────────┘ 文件末尾一句话读法:头部认格式,中间是普通 ZIP,尾部放校验与恢复信息。
MFP Header(32 字节)
固定长度,位于偏移 0,是识别 .mfp 的唯一依据。
| 字段 | 大小 | 说明 |
|---|---|---|
magic | 4 字节 | 4D 46 50 01,即 MFP\x01 |
formatVersion | 2 字节 | 主版本号,兼容性判定依据 |
flags | 2 字节 | 标志位 |
manifestOffset | 8 字节 | manifest.json 条目在文件中的偏移 |
| 其余 | 16 字节 | 保留 |
兼容性看的是 formatVersion
判断能否读取应依据 formatVersion(主版本号),不是 mfpVersion。 后者是产包工具或客户端的版本,与格式兼容性无关。
manifest.json
包内唯一的索引文件,描述全部字体、模板、校验与冲突信息。它本身是一个普通 ZIP 条目 (deflate 压缩),因此可以被任何 ZIP 工具直接取出查看。
用 draft 2020-12 的 JSON Schema 定义,可直接被各语言实现引用。最小可用形态只描述单个字体, 完整形态还包含集合字体、预览模板、冲突记录与恢复记录索引。
条目命名
字体条目以内容的 SHA-256 命名(例如 fonts/9f86d081....ttf)。这样做带来两个直接好处:
- 天然去重 —— 同一份字体重复加入只会产生一个条目
- 可校验 —— 条目名本身就是内容指纹,读取时可即时验证
字体数据条目使用 store(不压缩):字体文件本身已是二进制格式,压缩收益有限, 而 store 让随机访问与增量追加更直接。
关键约束
| 约束 | 内容 |
|---|---|
| 偏移修正 | 前置 32 字节 Header 后,必须同步修正 ZIP 结构中所有偏移字段 |
| 中央目录位置 | 必须位于数据区之后、EOCD 之前 |
| Footer 位置 | Footer Descriptor 必须紧跟 ZIP EOCD |
| 字节序 | Header 与 Footer 中所有多字节整数必须小端序 |
| 条目命名 | 字体条目以内容 SHA-256 命名,实现天然去重 |
关于偏移修正
这是实现时最容易出错的一步。ZIP 的权威索引是文件末尾的 EOCD, 其中记录的 offset of start of central directory 是相对文件起始的绝对偏移; 在前面插入 32 字节后,必须把偏移字段整体后移 32:
- EOCD 中的中央目录偏移
- 每一个中央目录条目中的 Local File Header 偏移
def patch_offsets(raw, delta):
"""把 ZIP 结构中的偏移字段整体后移 delta。"""
b = bytearray(raw)
eo = b.rfind(b"PK\x05\x06") # 定位 EOCD
cd_off = struct.unpack_from("<I", b, eo + 16)[0] # 中央目录偏移
cd_size = struct.unpack_from("<I", b, eo + 12)[0] # 中央目录大小
struct.pack_into("<I", b, eo + 16, cd_off + delta)
pos = cd_off
while pos < cd_off + cd_size and b[pos:pos + 4] == b"PK\x01\x02":
name_len = struct.unpack_from("<H", b, pos + 28)[0]
extra_len = struct.unpack_from("<H", b, pos + 30)[0]
cmt_len = struct.unpack_from("<H", b, pos + 32)[0]
lho = struct.unpack_from("<I", b, pos + 42)[0]
struct.pack_into("<I", b, pos + 42, lho + delta) # 每个条目一处
pos += 46 + name_len + extra_len + cmt_len
return bytes(b)实测发现:多数工具能容忍未修正的偏移
7-Zip、bsdtar、Python zipfile 三者对未修正偏移的文件也能打开, 因为它们各自实现了前缀补偿逻辑(用实际位置反推前缀长度)。 但这不构成可以不修正的理由:
- 这是各实现的宽容行为,不是 ZIP 规范的要求
- 依赖宽容行为会掩盖真正的偏移错误,问题被推迟到特定条目解压时才暴露
因此规范中的"必须修正"依然正确,实现指南建议的"打包后用独立 ZIP 库自检"依然必要 —— 它是唯一能可靠发现偏移漏改的手段。
尾部结构与尺寸边界
Footer Descriptor(24 字节)位于 EOCD 之后,恢复记录位于 Footer 之前:
[...ZIP 数据...][Central Directory][ZIP64 EOCD][Locator][EOCD][恢复记录][Footer Descriptor]
└── EOCD 之后的数据 ──┘ZIP 规范要求 EOCD 是文件最后一个结构,其后只允许注释,且注释长度记录在 EOCD 的 commentLength 字段里(2 字节,最大 65535)。主流实现的定位策略就是只回扫文件末尾 64 KB。
于是产生一个隐含的尺寸上限:
recoveryLength + 24(Footer) <= 65535- 不超过:严格实现仍能在 64 KB 窗口内找到 EOCD,正常打开
- 超过:EOCD 被推出搜索窗口,bsdtar 与 Python
zipfile会报"不是压缩包"
7-Zip 因搜索范围更宽而不受此限制。完整的实测数据与修复建议见 通用工具兼容性。