KBEngine 内嵌 Python 标准库 binhex 模块:BinHex4 编码与解码全解析

原创2026-10-16 16:47:101,940 阅读
文章标签:游戏开发后端

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 字节(默认 '????'),Flags 2 字节,共同组成 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)       # 断言往返无损

该测试同时验证了两个关键点:

  1. 无损往返:DATA = b'Jack is my hero' 经 binhex() → hexbin() 后与原文逐字节相等;
  2. 长文件名抛出 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" 一节指出了三个要点,结合源码可以进一步展开:

  1. 存在更强力的替代接口:文档提到"coded/decoder 有一个替代的、更强大的接口,详见源码"。从源码结构看,指的就是面向对象的底层流水线类——编码侧的 BinHex(配合 _Rlecoderengine、_Hqxcoderengine)与解码侧的 HexBin(配合 _Rledecoderengine、_Hqxdecoderengine)。它们暴露了 write() / read()、close_data()、write_rsrc() / read_rsrc() 等细粒度方法,允许调用方分批写入/读取数据分叉与资源分叉,而不是像顶层函数那样要求把数据全部经 128000 字节分块流式处理。

  2. 非 Macintosh 平台上的文本文件换行仍是旧 Mac 约定:文档明确指出"如果你在非 Mac 平台编码或解码文本文件,它们仍将使用旧的 Macintosh 换行约定(回车 \r 作为行结束符)"。源码两处注释 XXXX Do textfile translation on non-mac systems(Lib/binhex.py)证实跨平台换行转换是留待实现的 TODO,模块只做字节级透明传输。因此在 Linux 等平台上处理文本文件时,应自行负责 \r 与 \n 的转换。

  3. 仅处理数据分叉:文档明确"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 值)在不同编码器实现间存在歧义,跨实现交换数据时需留意。

八、相关资源导航

如果想继续深入,仓库内可直接查阅以下文件:

概括而言:binhex 是 CPython 标准库中一个"小而完整"的格式转换模块,在 KBEngine 内嵌 Python 环境中可开箱即用。掌握它的两个顶层函数、Error 异常语义、输出折行/文件头/CRC 布局,以及"数据分叉、旧式换行、文件名字段 63 字节上限"三个关键边界,就足以在脚本层安全地完成 BinHex4 数据的编码、解码与异常处理。

登录后查看全文
kbengine