Repository navigation
feat: 新增 Fcitx5/libime 拼音词库支持(二进制 libimebin + libimetxt 修复) - #426
Merged
Merged
Conversation
新增格式 libimebin(.dict,导入/导出) - CedarFloatTrie: 移植 libime DATrie<float>(Cedar 双数组 Trie),修正迭代器 Begin/Next 别名 bug,与 libime C++ 读取器逐字节比对一致 - LibimeDictFormat: 头部 magic 0x000fc613 + version;version 2 为 zstd 压缩 (ZstdSharp.Port) + 校验和,version 1 为原始字节 - LibimePinyinTable: 448 音节 + lue/nue(lve/nve) 编码表,整串拼音编解码 - 产物可被 Fcitx5 与 libime_pinyindict 直接加载,双向验证字节级一致 修复 libimetxt(文本拼音词库) - 导入器把两列读反: 原按「拼音 汉字」解析,而 libime 文本实为「汉字 拼音 [词频]」, 导致词面与拼音互换;导出器本就正确,故此前自身往返必然错位 - 词频可省略(2 列),并严格按 loadTextImpl 只接受 2/3 列 - 导出改用无 BOM UTF-8,与 saveText 一致(原 BOM 会混入首条词面首字符) - 补齐 fcitx 词面转义: 含空白/引号/反斜杠的词面按 escapeForValue 加引号转义, 导入按 consumeMaybeEscapedValue 还原(此前这类词面被拆成多列或丢字符) 验证 - 与 fcitx5 stringutils.cpp 逐字移植编译的 C++ 参考实现对拍: 边界语料 139 行 + 随机语料 20038 行,输出完全一致(上游 unescapeMap 带符号索引越界已规避) - 全量单测 388 通过 / 3 跳过 / 0 失败;解决方案 0 警告 0 错误 - --list-formats 集成断言补充 libimebin,README 格式速查表补 libimetxt
问题 libimetxt 导出含 CJK 扩展 B 及以后汉字(U+20000+,如 𫚉𩽾𬘭,UTF-16 中为 代理对)的词条时,这些字的拼音被清成 '' 且后续字拼音整体前移,例如: 口𠱞子早鸟 kou'ran'zi'zao'niao -> kou'''zao'niao 豹江𫚉娘 bao'jiang'hong'niang -> bao'jiang'' 𩽾𩾌 an'kang -> 整条被按空编码丢弃 34 条输入因此只导出 21 条,与 libime 自身输出不一致。 根因 CodeGenerationPostProcessor 按 UTF-16 码元遍历 word 并与 Code.Segments 按 下标配对。代理对的两个码元各自被 IsPunctuationOrSymbol 判为「标点/符号」, 于是 (1) 该码元对应的 segment 被清空,(2) 下标错位使后续字的拼音落到错误的 字上并被清掉。a3cbd97 只把 CJK 扩展 A 区(U+3400-U+4DBF)补进了 IsCJK, 非 BMP 汉字仍落在判定之外。 修复 - CodeGenerationPostProcessor: 改为按 Unicode 码点遍历(一码点一段), IsCJK 覆盖扩展 B-F/兼容补充(U+20000-U+2FA1F)、扩展 G-H(U+30000-U+323AF) 与兼容表意文字(U+F900-U+FAFF);落单代理项走安全分支,不走会抛异常的 char.ConvertToUtf32 - PinyinCodeGenerator: 同样按码点产出 segment,保持「一码点一段」不变式, 避免生成编码路径把代理对拆成两段而错位 验证 - 用户提供的 34 条 libime 语料 libimetxt->libimetxt: 34/34 导出, 逐行与 libime 输出一致(仅末行换行差异,为 saveText 正常行为) - 新增回归测试: 后处理器 8 条(非 BMP 保留、真标点仍清、非 BMP 后接 数字/英文/标点不串位、落单代理项不抛异常)、拼音生成集成 2 条、 libimetxt 整表黄金用例 1 条 - 反向验证: 撤掉修复后新增用例 7 条失败,确认为有效守卫 - 全量单测 398 通过 / 3 跳过 / 0 失败;解决方案 0 警告 0 错误
问题 libimetxt 导出两类词条仍不正确: 1. 私用区(PUA, U+E000-U+F8FF)承载的生僻字拼音被清成 '', 如「<PUA>汗 po'han」->「<PUA>汗 'han」、「镆<PUA> mo'ye」->「mo'」 2. 姓名中含无声母符号「·」的词条拼音整体前移, 如「芭芭拉·巴布科克 ba'ba'la'ba'bu'ke'ke」->「ba'ba'la''bu'ke'ke」 根因 1. IME 词库用私用区表示生僻字,这些码点不在 IsCJK 任何范围内, 被 IsPunctuationOrSymbol 判为标点而清空 2. libime 文本只写有音节的拼音(「芭芭拉·巴布科克」8 个字仅 7 个音节), 编码后处理此前按「字下标 == 段下标」1:1 配对,「·」占用了第 4 个 segment,导致其后所有音节前移一位 修复 - IsCJK 补充私用区(U+E000-U+F8FF)、CJK 部首补充+康熙部首(U+2E80-U+2FDF)、 CJK 笔画(U+31C0-U+31EF)、带圈 CJK+兼容区(U+3200-U+33FF)、 带圈表意补充(U+1F200-U+1F2FF) - 编码后处理对齐改为按「非标点码点数」判定: 仅当非标点码点数恰好等于 segment 数时,视为 libime 省略标点拼音,标点不占用 segment; 否则退回逐码点 1:1 配对(兼容生成路径为每个码点都产出 segment 的约定) 验证 - 用户 21 行语料(PUA 生僻字 + · 姓名)libimetxt->libimetxt: 21/21 逐行一致 - 新增回归测试: 后处理器 7 条(PUA/部首/笔画保留、libime 省略标点对齐、 生成路径标点空段仍正确处理)、libimetxt 端到端 4 条 - 反向验证: 撤掉修复后新增用例 8 条失败,确认为有效守卫 - 全量单测 409 通过 / 3 跳过 / 0 失败;解决方案 0 警告 0 错误
# Conflicts: # tests/integration/test-cases/4-regression/verify-list-formats.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
一、摘要
本 PR 让 imewlconverter 原生支持 Fcitx5 / libime 拼音引擎的词库,不再需要 Linux 或
libime_pinyindict工具链:libimebin(导入 + 导出):读写 libime 的二进制拼音词库(*.dict,即 Fcitx5 的sc.dict/ 用户词库格式)。输出的文件可被 Fcitx5 与libime_pinyindict直接加载,已做字节级对齐验证。libimetxt(文本拼音词库)的四个缺陷:其中「汉字 / 拼音两列读反」会让该格式自身往返必然错位;另修复 BOM 污染首条词面、缺失词面转义、未按 libime 规则限制列数。·等无声母符号的词条拼音整体前移。二、新增
libimebin:二进制格式要点2.1 文件容器
要点:
marshall(),其实现按大端写)。ZSTD_initCStream(level 0)(等价于 libzstd 默认级别)并开启帧校验和(ZSTD_c_checksumFlag = 1)。本实现用ZstdSharp.Port的流式 API 并同样置位 checksum 标志。peek()触发 footer 解析来验证 CRC,校验失败会抛Failed to load dict data。本实现的DecompressionStream(checkEndOfStream: true)对应这一行为。.dict文件 = 一棵 trie = 一个词库段(libime 的 SystemDict / UserDict 是不同文件,不在单个文件内分段)。sc(与 libime 系统词库sc.dict一致),扩展名.dict,IsBinary = true。2.2
DATrie<float>序列化布局zstd 解压后的字节流(即 libime
DATrie::save()的输出):tailLengthsize(节点数,ninfo/array长度)tail尾链字节tailLength × u8(其中 float 值小端,见下)array:size × { base, check }bheadF、bheadC、bheadOninfo:size × { sibling, child }block:(size >> 8) × { prev, next, num, reject, trial, ehead }关键易错点(本实现踩过)——端序不统一:
上述所有结构体字段都走 libime 的
marshall(),即大端。但尾链中保存的词值 float32 是小端。libime
utils_p.h明确注释:对应
storeDWord/loadDWord(htole32/le32toh)。写成大端会导致 libime 读出的词频是垃圾值。其他隐含约定:
block的数量恒为size >> 8(ninfo与array同长,每 256 个节点一个 block)。shrink_tail()(尾链压缩/重排),libimesave()同样调用;否则尾链偏移语义不一致。CEDAR_NO_VALUE = 0x7FC00001、CEDAR_NO_PATH = 0x7FC00002。2.3 键(key)的编码
每个词条的 trie 键 = 拼音字节串 +
'!'+ 汉字 UTF-8:分隔符
!(0x21)即 libime 的pinyinHanziSep。每个音节固定 2 字节 =(声母, 韵母),取值直接复用 libime
PinyinEncoder的枚举数值(枚举值就是 ASCII 码,因此可读性很好):PinyinInitialB = 65B P M F D T N L G K H J Q X ZH CH SH R Z C S Y W Zero,Zero = 88表示零声母PinyinFinalA = 65A AI AN ANG AO E EI EN ENG ER O ONG OU I IA IE IAO IU IAN IN IANG ING IONG U UA UO UAI UI UAN UN UANG V VE UE NG Zero Letter_A..Letter_Z例:
ni→(N=71, I=78);hao→(H=75, AO=69);xiao→(X=78, IAO=81);zhong→(ZH=79, ONG=76);e→(Zero=88, E=70);lv→(L=72, V=96);lve→(L=72, VE=97)。音节表覆盖 450 条:422 个规范音节(取自 libime
pinyindata) + 26 个单字母音节(A–Z→(Zero, Letter_x),用于X光这类词)+ 2 个 VE_UE 模糊别名(lue/nue→lve/nve),与encodeFullPinyinWithFlags(pinyin, VE_UE)的行为一致。即:文本格式里的
lue/nue与二进制里是同一个键,两种格式可以互转而不丢信息。大写字母音节(
X'guang)是合法输入,已包含在表中。无法编码的音节(生僻表外拼写)→ 该词条计入
ErrorCount并跳过;若全部词条都无法导出,则抛异常,避免写出空文件或损坏文件。键本身是字节串,不涉及端序问题。
2.4 值(value)
float32,语义是 libime 的「权重 / 代价」(对应文本格式的第 3 列)。本 PR 与libimetxt保持一致,直接用WordEntry.Rank。导入时float → int(clamp;非有限值 → 0)。2.5 新增文件
src/ImeWlConverter.Formats/LibIMEPinyin/CedarFloatTrie.csDATrie<float>(cedar 双数组 trie)的 C# 移植:Set(构建)、Save/Load(序列化)、Enumerate(遍历导出键值对)。含shrink_tail、block/ninfo 维护与尾链分配src/ImeWlConverter.Formats/LibIMEPinyin/LibimePinyinTable.csTryEncode/DecodeSyllable/DecodeFullPinyinsrc/ImeWlConverter.Formats/LibIMEPinyin/LibimeDictFormat.cssrc/ImeWlConverter.Formats/LibIMEPinyin/LibIMEPinyinExporter.cs[FormatPlugin("libimebin", …, 501, IsBinary = true, FileExtension = ".dict", DefaultFileName = "sc")]src/ImeWlConverter.Formats/LibIMEPinyin/LibIMEPinyinImporter.cs配套工程改动:
ImeWlConverter.Formats.csproj:新增 NuGet 依赖ZstdSharp.Port 0.8.8(仅 Formats 项目;纯托管实现,无原生依赖,跨平台)。ImeWlConverter.Formats.csproj:新增InternalsVisibleTo("ImeWlConverterCoreTest"),供测试直接验证内部 trie 与音节表。2.6 为什么要自己移植 trie
.NET 没有可用的 cedar/CEDAR 双数组 trie 实现,而 zstd 有成熟的
ZstdSharp.Port。因此 trie 部分按 libimelibime/core/datrie.cpp移植了构建 + 序列化 + 遍历所需子集(未移植删除等无关操作),目标是字节级兼容而非仅语义兼容。三、修复
libimetxt(文本拼音词库)libime 文本格式(官方文档
pinyindictionary-text-format.md):'分隔音节;允许大写(X'guang);权重可省略(省略时按0载入,但回写时一定补上)。#开头行)。"包裹并按 fcitx 规则转义。3.1 修复 1(严重):导入器把「汉字 / 拼音」两列读反
原实现:
而 libime 的实际顺序是「汉字 拼音 [权重]」。因为导出器本来是正确的,所以该格式的自身往返(导出→导入)必然错位(词面与拼音互换),且 2 列行(合法,权重可省略)会被直接丢弃。
修复后:
3.2 修复 2:导出带 BOM,污染首条词面
FileEncoding原为Encoding.UTF8(带 BOM),libime 会原样把 BOM 读成第一个词面的首字符(U+FEFF)。改为new UTF8Encoding(false),与saveText一致。3.3 修复 3:补齐 fcitx 词面转义(新增
LibimeTextEscaping.cs,158 行)原先按空格
Split,词面含空格的词条会被拆成多列;含引号/反斜杠也无法还原。libime 通过 fcitx 的escapeForValue/consumeMaybeEscapedValue处理,本 PR 做了等价的 C# 移植:需要加引号的字符集:
\f \r \t \v 空格 " \ \n转义表(
escapeMap/unescapeMap):\\\"\"\n(LF)\n\f\f\r\r\t\t\v\v空白集合
FCITX_WHITESPACE = "\f\n\r\t\v "(注意不包含普通空格之外的其它 Unicode 空白)。解析:跳过前导空白 → 若以
"开头则读到下一个未转义的";否则读到下一个空白。非法转义序列按原字符处理(丢弃反斜杠),与 fcitx 一致。引号未闭合时,fcitx 回退为「按未加引号处理」,本实现同样回退。
于是
a b这类词面导出为"a b" ni'hao 5,导入可完整还原。3.4 修复 4:列数校验与 libime 对齐
只接受 2 列或 3 列,其余整行忽略(原为「≥3 列」才处理)。
四、修复:拼音编码生成对特殊字符的处理
4.1 代理对导致的「拼音被清空 + 整体串位」
CodeGenerationPostProcessor原先按 UTF-16 码元遍历word并与Code.Segments按下标配对。BMP 之外的汉字(CJK 扩展 B 及以后,如𫚉 U+2B689、𩽾 U+29F7E、𬘭)在 UTF-16 中是代理对,于是:实际表现(用户反馈语料,34 条输入只导出 21 条):
修复:
CodeGenerationPostProcessor改为按 Unicode 码点遍历(一个码点对应一个 segment);IsCJK补齐 BMP 之外的范围:CJK 扩展 B–F 与兼容补充(U+20000–U+2FA1F)、扩展 G–H(U+30000–U+323AF)、兼容表意文字(U+F900–U+FAFF)、带圈表意补充(U+1F200–U+1F2FF);char.ConvertToUtf32;PinyinCodeGenerator同样按码点产出 segment,维持「一个码点一个 segment」的不变式,避免生成编码时再次错位。4.2 私用区(PUA)生僻字被当标点清空
IME 词库会用私用区承载生僻字(本次语料里那些看起来像异体字的字形),这些码点此前不在
IsCJK内:修复:
IsCJK增补私用区(U+E000–U+F8FF),并顺带补齐 CJK 部首补充+康熙部首(U+2E80–U+2FDF)、CJK 笔画(U+31C0–U+31EF)、带圈 CJK 与兼容区(U+3200–U+33FF)。4.3 含「·」等无声母符号的词条拼音整体前移
libime 文本只写有音节的拼音——「芭芭拉·巴布科克」8 个字只有 7 个音节,
·没有音节。原先「字下标 == 段下标」严格 1:1 配对时,·占用了第 4 个 segment,其后所有音节前移一位:修复(在按码点对齐的基础上):
这样文本导入(省略标点)与编码生成(标点空段)两种来源都能正确对齐。
五、兼容性与行为变化
libimetxt导入结果会变化:字段顺序修正后,此前「词面↔拼音互换」的错误结果被改正;若有人依赖旧行为,会看到修正后的数据。libimetxt导出不再写 BOM:首条词面不再带U+FEFF。ZstdSharp.Port 0.8.8(仅ImeWlConverter.Formats,纯托管)。[FormatPlugin]+ 源生成器自动注册,CLI/GUI 无需改动即出现在格式列表中。libimetxt(此前遗漏)与libimebin;--list-formats集成断言同步补充libimebin。六、测试
新增/增补的测试:
LibIMEPinyinTest.cs(新增)LibIMETextTest.cs(新增)·省略标点对齐、PUA 字符保持CodeGenerationPostProcessorTest.cs(增补)PinyinCodeGenerationIntegrationTest.cs(增补)结果:
交叉验证方法(重点)
datrie.cpp与PinyinEncoder编译了一个ref.exe build|dump参考程序;src/lib/fcitx-utils/stringutils.cpp的escapeForValue/consumeMaybeEscapedValue(含escapeMap/unescapeMap)原样编译成参考程序,与 C# 实现做十六进制逐行 diff:·)在libimetxt → libimetxt下逐行一致(唯一差异是 libimesaveText会补行尾换行,属正确行为)。七、如何手工验证
八、已知限制 / 后续可做
encodeFullPinyinWithFlags(..., VE_UE)对齐(lue/nue→lve/nve)。libime 的其它PinyinFuzzyFlag(zh/z、ch/c、sh/s、l/n、f/h、r/l、an/ang、en/eng、in/ing等)未实现。int:libime 的权重是float,文本格式第 3 列也是浮点数。目前文本导入用int.TryParse(如0.5会得 0),二进制导入 clamp 到int,导出写int。如需完整保真,建议后续把WordEntry.Rank扩展为float(改动面较大,本 PR 未做)。DATrie::set语义一致),不做合并。libimebin的Enumerate为全量遍历,超大词库(百万级)导入时内存占用与原文本格式相当,未做流式优化。九、变更文件总览
src/ImeWlConverter.Formats/LibIMEPinyin/CedarFloatTrie.cssrc/ImeWlConverter.Formats/LibIMEPinyin/LibimePinyinTable.cssrc/ImeWlConverter.Formats/LibIMEPinyin/LibimeDictFormat.cssrc/ImeWlConverter.Formats/LibIMEPinyin/LibIMEPinyinExporter.cssrc/ImeWlConverter.Formats/LibIMEPinyin/LibIMEPinyinImporter.cssrc/ImeWlConverter.Formats/LibIMEText/LibimeTextEscaping.cssrc/ImeWlConverter.Formats/LibIMEText/LibIMETextImporter.cssrc/ImeWlConverter.Formats/LibIMEText/LibIMETextExporter.cssrc/ImeWlConverter.Core/CodeGeneration/CodeGenerationPostProcessor.cssrc/ImeWlConverter.Core/CodeGeneration/Generators/PinyinCodeGenerator.cssrc/ImeWlConverter.Formats/ImeWlConverter.Formats.csprojsrc/ImeWlConverterCoreTest/LibIMEPinyinTest.cssrc/ImeWlConverterCoreTest/LibIMETextTest.cssrc/ImeWlConverterCoreTest/GeneraterTest/CodeGenerationPostProcessorTest.cssrc/ImeWlConverterCoreTest/GeneraterTest/PinyinCodeGenerationIntegrationTest.csREADME.mdlibimetxt、libimebinCHANGELOG.mdtests/integration/test-cases/4-regression/verify-list-formats.shlibimebin