Skip to content

fix: libimebin 导出的 *.dict 与 libime/Fcitx5 逐字节一致 - #429

Merged
studyzy merged 1 commit into
studyzy:masterfrom
Nahuimi:fix/libimebin-byte-exact
Oct 10, 2026
Merged

studyzy merged 1 commit into
studyzy:masterfrom
Nahuimi:fix/libimebin-byte-exact

Conversation

@Nahuimi

@Nahuimi Nahuimi commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

问题

libimebin(Fcitx5 / libime 二进制拼音词库,容器 version 0x2)导出的 *.dict 与 libime_pinyindict / Fcitx5 写出的同内容文件不字节一致:

  • 小词库:一致;
  • 大词库(实测 42,998,611 B 文本、1,498,781 条词条):我们的产物少 13 字节,SHA256 不同。

即「用官方工具转换」和「用本工具转换」得到的词库对不上哈希,需要逐字节复现上游产物的流程无法通过。

原因

libime 的 writeZSTDCompressed() 不是一次普通的 zstd 压缩调用,而是一条 Boost.Iostreams 过滤链:

std::ostream → boost::iostreams::chain(indirect_streambuf, 128B)
             → ZSTDCompressor(symmetric_filter, 4096B 输出缓冲)
             → libzstd(ZSTD_initCStream(level 0) + ZSTD_c_checksumFlag=1)

关闭时 symmetric_filter::close() 会用空输入反复调用 filter(flush=true),直到 4096B 输出缓冲能排空。如果关闭那一刻 zstd 内部积压的输出超过一个缓冲,循环就要多跑一轮:这一轮的 ZSTD_compressStream 把当前帧的剩余输出写完,紧接着的 ZSTD_endStream 于是新开并立即结束了一个空帧,文件尾部因此多出 13 字节:

28 b5 2f fd | 24 | 00 | 01 00 00 | 99 e9 d8 51
└─ magic ──┘   │    │   └ 末块/raw/长度 0 ┘  └ xxhash64("") 低 32 位 ┘
      帧头描述符 0x24(单段 + 校验和),内容长度 0

关键点:是否出现该空帧取决于数据(取决于关闭时刻 zstd 内部的积压量),不是固定的大小阈值——小词库不会出现,大词库会出现。所以不能简单「在文件末尾补 13 字节」,必须复刻同一套缓冲与 flush 循环。

改动

  • 新增 LibimeZstdWriter(src/ImeWlConverter.Formats/LibIMEPinyin/LibimeZstdWriter.cs):通过 ZstdSharp.Unsafe 直接驱动 libzstd 流式 API,逐字节复刻上面这条链路——128B 外层缓冲、4096B 过滤器缓冲、write/close 的 flush 循环、ZSTD_initCStream(0) + ZSTD_c_checksumFlag=1。
  • LibimeDictFormat.Write 改用它替代原来的 CompressionStream 一次性压缩。
  • 文件格式与版本号未变(magic 0x000fc613、version 0x2),导入路径不受影响(仍支持 legacy 0x1 未压缩文件)。

验证

新增 3 个单元测试(LibIMEPinyinTest):

用例 断言
Export_ReproducesUpstreamLibimeBytes 上游 libime 写出的 1352 B 词库读入后原样写出逐字节相同,锁定文件头、zstd 参数与 trie 序列化
Export_LargeDict_EndsWithLibimeTrailingEmptyFrame 压缩主体 > 4096 B 时尾部必须带那 13 字节空帧,且带空帧的文件仍能被完整读回
Export_SmallDict_HasNoTrailingEmptyFrame 小词库不带空帧(与上游一致)

真实词库回归:

CustomPinyinDictionary_Fcitx.txt   42,998,611 B  (1,498,781 条)
  → libimebin                      25,544,344 B
  SHA256 CAC84C5F48E9C47168A46B85D4DDFD8B15F64634DF2DBA5DDFA707F4AB8151A3

该哈希与 libime_pinyindict / Fcitx5 产出的参考文件逐字节相同,且 libimebin → libimebin 幂等。

全量测试:454 用例 / 451 通过 / 3 跳过 / 0 失败;CI 9/9 通过。

本地复现:

dotnet run --project src/ImeWlConverterCmd -- -i libimetxt -o libimebin -O out.dict in.txt
# 与 libime_pinyindict 的产物对比 sha256,或直接跑 LibIMEPinyinTest

兼容性

  • 只有导出产物的字节发生变化(补上此前缺失的尾部空帧),格式与版本号未变:旧文件照常读写,新文件仍被 libime / Fcitx5 正常加载。
  • 小词库的产物完全不变。
  • ZstdSharp.Port 精确锁定 0.8.8:字节一致依赖该移植版 zstd 的输出与上游 libzstd 相同,升级该包属于行为变更,需重跑上述测试并对比真实词库产物。

libime 的 writeZSTDCompressed 并非直接调用 zstd,而是走 Boost.Iostreams 过滤器链
(128B 外层缓冲 + 4096B symmetric_filter + ZSTDCompressor)。symmetric_filter::close()
用空输入反复 filter(flush=true) 直到缓冲排空;当关闭时 zstd 内部积压超过一个缓冲时,
循环会多跑一轮:这一轮的 ZSTD_compressStream 写完当前帧剩余输出,紧接的 ZSTD_endStream
便新开并立即结束一个空帧,于是帧尾多出 13 字节(28 b5 2f fd 24 00 01 00 00 99 e9 d8 51)。

此前用 CompressionStream 直连 zstd 会漏掉该帧,导致大词库比 libime_pinyindict / Fcitx5
产物少 13 字节、哈希不一致;小词库(压缩后不足一个缓冲)不触发,所以此前只在部分大词库上暴露。
空帧是否出现取决于关闭时刻的积压量,并非简单的大小阈值(随机数据在 128KiB 整数倍时同样不出现)。

新增 LibimeZstdWriter 逐字节复刻该链路(deflate 的 eof 语义、close 的多轮 flush 循环、
双层缓冲),底层改用 ZstdSharp.Unsafe 的原生流式 API;LibimeDictFormat.Write 改用它。

验证:
- 与真实上游文件做容器层往返(解压 → 读入 trie → 重新写出),要求逐字节一致:
  测试/libime、测试/小企鹅 的 ceshi-4.dict = 24696 A6BA7CED…;
  小企鹅/cpdf2.dict(25MB)= 25544344 CAC84C5F…;小词库 1352/1385/1429/1439 无空帧
- 与真实 Boost 1.86 + libzstd 1.5.7 搭出的参考实现对比:12 种载荷 × 5 种写入分片共 50 组
  全部逐字节一致(覆盖 4096 边界、128KiB 整数倍、52MB 载荷)
- 新增 3 个回归测试;全量测试 454 项:451 通过 / 3 手动跳过 / 0 失败
@studyzy
studyzy merged commit 501a1b7 into studyzy:master Oct 10, 2026
9 checks passed
@Nahuimi
Nahuimi deleted the fix/libimebin-byte-exact branch October 12, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants