KBEngine 内嵌 Python 标准库 binhex 模块:BinHex4 编码与解码全解析
KBEngine 内嵌 Python 标准库 binhex 模块:BinHex4 编码与解码全解析
本指南以 KBEngine 仓库中内嵌的 CPython 3.7.3 标准库文档 binhex.rst 为骨架,结合其完整源码实现 Lib/binhex.py、单元测试 Lib/test/test_binhex.py 与底层支撑模块 binascii.rst(实现于 Modules/binascii.c),系统讲解 BinHex4 格式的原理、binhex() / hexbin() 两个顶层接口的用法、Error 异常的触发场景,以及编码器/解码器内部的 RLE 压缩、HQX 转码、CRC 校验与"旧 Macintosh 换行约定"等实现细节。读完本文,你将能在 KBEngine 服务端脚本(Python 3.7.3 环境,版本见 Include/patchlevel.h)中熟练完成 BinHex4 文件的编码、解码与错误处理,并能读懂其分层流水线式的底层实现。
说明:
binhex是随 KBEngine 内嵌的 CPython 解释器一并提供的标准库模块,服务于服务端 Python 脚本环境(KBEngine 通过 pyscript 的 scriptobject.h 等桥接层嵌入 Python)。它在本仓库中的作用是向服务端脚本提供与 Python 3.7 标准库完全一致的 ASCII 化二进制传输能力,不涉及 KBEngine 网络/实体框架本身的定制。
一、模块概览:BinHex4 是什么,用来做什么
binhex 模块用于对 binhex4 格式 的文件进行编码与解码。BinHex4 是一种"把 Macintosh 文件表示为 ASCII 文本"的格式,使其可以通过纯文本通道(邮件、串口、BBS、文本终端等)传输而不被破坏。根据模块文档:
- 该格式允许将 Macintosh 文件表示为 ASCII 形式,模块只处理数据分叉(data fork),资源分叉在编码时被忽略(
openrsrc类在 Lib/binhex.py 中是一个读写均返回空字节的占位实现); - 模块定义了两个顶层函数
binhex(input, output)与hexbin(input, output),以及一个异常Error; - 与
binascii模块协同工作——binascii负责 ASCII 与二进制之间的底层互转(见 binascii.rst),binhex在其上叠加了 RLE 压缩、HQX 编码、CRC 校验与文件头/分叉组织。
模块开头的 docstring 与代码注释记录了这段历史:模块由 Jack Jansen(CWI,1995 年 8 月)编写,设计目标是与各平台"尽可能兼容",简单接口在任何平台上都应"按预期工作"(Lib/binhex.py)。
从源码看整体结构
Lib/binhex.py 的骨架可概括为"两条流水线 + 顶层入口":
| 组成 | 位置 | 职责 |
|---|---|---|
_Hqxcoderengine |
L79-L115 | 编码侧:把二进制按 3 字节分块做 b2a_hqx 转码,并按 LINELEN 长度折行 |
_Rlecoderengine |
L117-L137 | 编码侧:按 REASONABLY_LARGE 阈值对数据做 RLE 压缩 |
BinHex |
L139-L231 | 编码侧:写文件头、数据、CRC,管理状态机 |
_Hqxdecoderengine |
L254-L294 | 解码侧:按 4 字节分块做 a2b_hqx 解码,处理不完整输入 |
_Rledecoderengine |
L296-L348 | 解码侧:RLE 解压,小心处理末尾孤立的 RUNCHAR |
HexBin |
L350-L452 | 解码侧:读文件头、校验 CRC、读取数据分叉/资源分叉 |
binhex() / hexbin() |
L233-L252 / L454-L479 | 顶层便利接口 |
编码侧的分层顺序是:binhex() → BinHex(组织头部与 CRC)→ _Rlecoderengine(RLE 压缩)→ _Hqxcoderengine(HQX 转码 + 折行)→ 输出流;解码侧则是逆序的流水线。这种设计把"格式组织"(头部、长度、CRC)与"底层转码"(RLE、HQX)彻底解耦。
二、编码接口:binhex(input, output)
文档定义如下:
binhex(input, output):将文件名为 input 的二进制文件转换为 binhex 文件 output。output 参数既可以是一个文件名,也可以是任意支持write()和close()方法的文件类对象。
对应实现为 Lib/binhex.py:
def binhex(inp, out):
"""binhex(infilename, outfilename): create binhex-encoded copy of a file"""
finfo = getfileinfo(inp)
ofp = BinHex(finfo, out)
with io.open(inp, 'rb') as ifp:
# XXXX Do textfile translation on non-mac systems
while True:
d = ifp.read(128000)
if not d: break
ofp.write(d)
ofp.close_data()
ifp = openrsrc(inp, 'rb')
while True:
d = ifp.read(128000)
if not d: break
ofp.write_rsrc(d)
ofp.close()
ifp.close()
关键行为细节
- 输入必须按二进制打开:
getfileinfo用io.open(name, 'rb')读取输入(Lib/binhex.py),随后主体循环同样以二进制模式io.open(inp, 'rb')按 128000 字节 的大块读取并写入编码器。这也解释了文档强调的"只处理数据分叉"——编码时资源分叉通过openrsrc占位类被静默忽略。 - 输出可以是文件名或文件类对象:
BinHex.__init__中通过isinstance(ofp, str)判断——若是字符串则内部io.open(ofname, 'wb')打开文件并在出错时关闭(close_on_error),否则直接使用外部传入的、已支持write()/close()的对象(Lib/binhex.py)。这一设计让调用方可以自由接入io.BytesIO、socket 包装流等场景。 - 输出文件的起始标记:编码输出以固定前缀
(This file must be converted with BinHex 4.0)\r\r:开头(Lib/binhex.py),该头为后续hexbin定位数据起始冒号提供依据。 - 文本文件探测:
getfileinfo会读取输入前 512 字节,若其中不含 0 字节则把文件 Type 标记为'TEXT'(Lib/binhex.py);文件名中的:会被替换为-。从源码注释看,跨平台的换行转换(如把\n转为 Mac 的\r)目前是"TODO"状态,因此编码结果保持原始字节。
编码输出格式:以 64 字节折行
_Hqxcoderengine 体现了 BinHex4 的 ASCII 排版约定:
- 常量
LINELEN = 64、RUNCHAR = b"\x90"(十六进制0x90)定义在模块顶部(Lib/binhex.py); - 编码器先把二进制按 3 字节一组 交给
binascii.b2a_hqx转成 4 个 ASCII 字符,再把 HQX 数据以 64 字符为一行输出,行末追加\n;数据结束处以:收尾(_flush(1)写入self.hqxdata + b':\n',Lib/binhex.py)。
这与 binascii 文档中的约定一致:b2a_hqx 要求输入"已经 RLE 编码且长度可被 3 整除(最后一个片段除外)"(binascii.rst)。
RLE 压缩:0x90 转义约定
_Rlecoderengine 是编码流水线中位于 HQX 转码之前的压缩层:
class _Rlecoderengine:
def write(self, data):
self.data = self.data + data
if len(self.data) < REASONABLY_LARGE:
return
rledata = binascii.rlecode_hqx(self.data)
self.ofp.write(rledata)
self.data = b''
- 常量
REASONABLY_LARGE = 32768定义了"凑够多少字节才做一次 RLE 压缩"的批量阈值; - 压缩语义在 binascii.rst 中有明确定义:
0x90紧跟一个字节作为"重复指示符",再跟一个计数值;计数值 0 表示该字节就是字面量0x90。也就是0x90既是重复前缀又是被转义的字节值——这正是解码端需要小心处理孤立 RUNCHAR 的原因。
三、解码接口:hexbin(input, output)
文档定义如下:
hexbin(input, output):解码 binhex 文件 input。input 可以是文件名或支持read()和close()方法的文件类对象。结果写入名为 output 的文件;若 output 参数为None,则输出文件名从 binhex 文件本身读取。
对应实现为 Lib/binhex.py:
def hexbin(inp, out):
"""hexbin(infilename, outfilename) - Decode binhexed file"""
ifp = HexBin(inp)
finfo = ifp.FInfo
if not out:
out = ifp.FName
with io.open(out, 'wb') as ofp:
# XXXX Do translation on non-mac systems
while True:
d = ifp.read(128000)
if not d: break
ofp.write(d)
ifp.close_data()
d = ifp.read_rsrc(128000)
if d:
ofp = openrsrc(out, 'wb')
ofp.write(d)
while True:
d = ifp.read_rsrc(128000)
if not d: break
ofp.write(d)
ofp.close()
ifp.close()
关键行为细节
- 自动定位数据起始点:
HexBin.__init__会逐个字节扫描,跳过所有\r,直到找到第一个:(Lib/binhex.py)。这种宽容策略正是为了兼容"以\r\n结尾的行在读取时表现为\n\r"等边界情况;若读到文件末尾仍无:,则抛出Error("No binhex data found")。 - output 为 None 时恢复原始文件名:文件头第一字节是文件名字节长度,其后紧跟拉丁-1 编码的文件名(Lib/binhex.py),解码时把它恢复为输出文件名(
if not out: out = ifp.FName)。 - 数据分叉与资源分叉分离写入:先写数据分叉,然后若存在资源分叉数据(通过占位类
openrsrc写入,实际在 Lib/binhex.py 中为 no-op),同样按 128000 字节分块处理。 - 解码输入也可以是文件类对象:
HexBin.__init__同样用isinstance(ifp, str)判断,字符串时内部以二进制打开,否则直接使用传入对象(Lib/binhex.py)。
解码器的难点:孤立 RUNCHAR 处理
_Rledecoderengine._fill 是全文最"绕"的代码(注释自嘲为 "Obfuscated code ahead")。因为 RLE 解码流中可能残留半个重复序列,它必须根据缓冲区结尾形态决定"这一批解码多少字节"(Lib/binhex.py):
| 缓冲末尾形态 | 保留字节数 | 原因 |
|---|---|---|
\x90\0\x90 |
保留 3 字节 | 连续重复的 \x90(以 \x90\0 转义) |
?\x90 |
保留 2 字节 | 可能是"重复其他字节"序列的开头 |
\x90\0 |
保留 2 字节 | 转义过的 \x90 |
?\x90? |
全部解码 | 完整的重复序列 |
| 其他 | 保留 1 字节 | 常规等待后续字节 |
_Hqxdecoderengine 则负责处理 HQX 解码的"补字节"问题:binascii.a2b_hqx 可能抛出 binascii.Incomplete,此时解码器会再读 1 字节拼入数据重试,直到成功或遇到真正的 EOF(Error('Premature EOF on binhex file'),Lib/binhex.py)。
四、错误处理:Error 异常
文档定义如下:
Error:当某些内容无法用 binhex 格式编码(例如文件名太长、无法放入文件名字段)时,或当输入不是正确的 binhex 编码数据时,抛出该异常。
在源码中,Error 是模块自定义的异常类(Lib/binhex.py),并通过 __all__ = ["binhex","hexbin","Error"] 暴露为公开 API。实际抛出点包括:
- 文件名过长:
_writeinfo中nl = len(name),当nl > 63时抛出Error('Filename too long')(Lib/binhex.py)——这是 binhex4 文件名字段 1 字节长度上限(最多 255)与实现采用的 63 字节限制共同作用的结果; - 状态机违规:
write()/write_rsrc()/close_data()/close()中均校验当前状态,在错误时机写入数据会抛出Error('Writing data at the wrong time')等; - 长度不匹配:数据分叉或资源分叉实际写入长度与头部声明不一致时抛出
Error('Incorrect data size, ...')/Error("Incorrect resource-datasize, ...")(Lib/binhex.py); - 输入不是合法 binhex 数据:解码时找不到起始冒号、
Premature EOF、CRC 校验失败(Error('CRC error, computed %x, read %x'),Lib/binhex.py)都会抛出Error。
单元测试 test_binhex.py 专门验证了长文件名路径:使用一个超长文件名调用 binhex.binhex(),断言必须抛出 binhex.Error 实例。
五、文件头格式与 CRC 校验:读一下底层实现
编码器 BinHex._writeinfo(Lib/binhex.py)展示了 binhex4 文件头的真实字节布局:
d = bytes([nl]) + name.encode("latin-1") + b'\0' # 1字节文件名长度 + 文件名 + \0
d2 = tp + cr # Type(4) + Creator(4)
d3 = struct.pack('>h', finfo.Flags) # Flags,大端 2 字节
d4 = struct.pack('>ii', self.dlen, self.rlen) # 数据分叉长度 + 资源分叉长度,大端各 4 字节
info = d + d2 + d3 + d4
self._write(info)
self._writecrc()
几个值得注意的细节:
- 强制大端(big-endian):注释明确写着 "Force all structs to be packed with big-endian"(Lib/binhex.py),头部各字段与 CRC 均以大端序打包/解包,模块因此"不再依赖字节序"(L44 的注释 "This code is no longer byte-order dependent");
- FInfo 三件套:
Type、Creator各 4 字节(默认'????'),Flags2 字节,共同组成 Macintosh 文件信息(Lib/binhex.py); - CRC 采用 CRC-CCITT 多项式:
binascii.crc_hqx(data, value)使用多项式x^16 + x^12 + x^5 + 1(即0x1021),这正是 binhex4 标准指定的校验算法(binascii.rst)。编码侧_writecrc在写入头部后立即写入一个 2 字节 CRC;解码侧_checkcrc读出文件中存储的 CRC 并与之比对,不一致即抛Error。
解码侧 HexBin._readheader 是对称的解析流程:读 1 字节文件名长度 → 读文件名 → 读 1 + 4 + 4 + 2 + 4 + 4 字节的头部其余部分 → _checkcrc()(Lib/binhex.py)。
六、端到端实战:编码、解码与测试验证
最小可用示例
在 KBEngine 服务端的 Python 3.7.3 环境(版本定义于 Include/patchlevel.h)中,可以直接使用:
import binhex
# 编码:把二进制文件 mydata.bin 编码为 binhex 文件 mydata.hqx
binhex.binhex('mydata.bin', 'mydata.hqx')
# 解码:输出名显式指定
binhex.hexbin('mydata.hqx', 'restored.bin')
# 解码:output 传 None,从 binhex 文件头自动恢复原始文件名
binhex.hexbin('mydata.hqx', None)
# 输出也可以是文件类对象(支持 write/close 即可)
import io
buf = io.BytesIO()
binhex.binhex('mydata.bin', buf) # 编码进内存缓冲区
单元测试验证:编码-解码往返
仓库自带的测试 test_binhex.py 给出了标准的往返验证模式:
DATA = b'Jack is my hero'
f = open(self.fname1, 'wb')
f.write(self.DATA)
f.close()
binhex.binhex(self.fname1, self.fname2) # 编码
binhex.hexbin(self.fname2, self.fname1) # 解码回原名
f = open(self.fname1, 'rb')
finish = f.readline()
f.close()
self.assertEqual(self.DATA, finish) # 断言往返无损
该测试同时验证了两个关键点:
- 无损往返:
DATA = b'Jack is my hero'经binhex()→hexbin()后与原文逐字节相等; - 长文件名抛出
Error:test_binhex_error_on_long_filename用超长文件名调用binhex.binhex(),断言抛出binhex.Error(test_binhex.py),与文档中"文件名太长无法放入文件名字段"的异常说明完全对应。
解码出错时的表现
如果对非法输入调用 hexbin(),例如输入文件中不存在起始冒号,HexBin 会抛 Error("No binhex data found");若 HQX 数据中途耗尽则抛 Error('Premature EOF on binhex file');若数据被篡改导致 CRC 不匹配,则抛 Error('CRC error, computed %x, read %x')。实际使用中建议用 try/except binhex.Error 包裹解码调用。
七、注意事项与已知局限
文档 "Notes" 一节指出了三个要点,结合源码可以进一步展开:
-
存在更强力的替代接口:文档提到"coded/decoder 有一个替代的、更强大的接口,详见源码"。从源码结构看,指的就是面向对象的底层流水线类——编码侧的
BinHex(配合_Rlecoderengine、_Hqxcoderengine)与解码侧的HexBin(配合_Rledecoderengine、_Hqxdecoderengine)。它们暴露了write()/read()、close_data()、write_rsrc()/read_rsrc()等细粒度方法,允许调用方分批写入/读取数据分叉与资源分叉,而不是像顶层函数那样要求把数据全部经 128000 字节分块流式处理。 -
非 Macintosh 平台上的文本文件换行仍是旧 Mac 约定:文档明确指出"如果你在非 Mac 平台编码或解码文本文件,它们仍将使用旧的 Macintosh 换行约定(回车
\r作为行结束符)"。源码两处注释XXXX Do textfile translation on non-mac systems(Lib/binhex.py)证实跨平台换行转换是留待实现的 TODO,模块只做字节级透明传输。因此在 Linux 等平台上处理文本文件时,应自行负责\r与\n的转换。 -
仅处理数据分叉:文档明确"Only the data fork is handled"。编码时
openrsrc是空实现(Lib/binhex.py),资源分叉数据在编码时不会写入;解码时若 binhex 流中确实携带资源分叉数据,也会被写入openrsrc占位对象而丢弃。对只需要单数据流的场景(如服务端间传输配置、二进制资源、序列化产物)这完全够用。
此外,从模块头部注释(Lib/binhex.py)还能看到两条已知边界:简单例程"期望在内存中容纳完整文件"(XXXX The simple routines are too simple)以及 0x90 连续重复的转义语义(\x90 0x90 会被解读为转义后的 0x90 值)在不同编码器实现间存在歧义,跨实现交换数据时需留意。
八、相关资源导航
如果想继续深入,仓库内可直接查阅以下文件:
- 模块完整源码:Lib/binhex.py(479 行,含全部注释与 TODO 标记);
- 官方文档原文:Doc/library/binhex.rst;
- 底层转码支撑模块文档:Doc/library/binascii.rst(
a2b_hqx、b2a_hqx、rlecode_hqx、rledecode_hqx、crc_hqx、Incomplete); - 单元测试:Lib/test/test_binhex.py(往返验证 + 长文件名异常用例);
- 内嵌 Python 版本:Include/patchlevel.h(
PY_VERSION "3.7.3"); - 与 KBEngine 服务端脚本环境的桥接:pyscript 中的 scriptobject.h、pythread_lock.h 均直接
#include "Python.h",说明服务端脚本复用同一份 CPython 解释器。
概括而言:binhex 是 CPython 标准库中一个"小而完整"的格式转换模块,在 KBEngine 内嵌 Python 环境中可开箱即用。掌握它的两个顶层函数、Error 异常语义、输出折行/文件头/CRC 布局,以及"数据分叉、旧式换行、文件名字段 63 字节上限"三个关键边界,就足以在脚本层安全地完成 BinHex4 数据的编码、解码与异常处理。