Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/ImeWlConverter.Formats/GboardBinary/GboardBinaryImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ public Task<ImportResult> ImportAsync(
entries.Add(new WordEntry
{
Word = w.Word,
// FPT2 的 F1 字段即词频(Gboard 用它给候选排序, 越大越靠前)。
// 这里 +1: 上游的 DefaultWordRankGenerator 会把 Rank==0 的条目
// 覆盖成 1, 而 F1 从 0 开始, 直接映射会让 0 和 1 两个频次撞在一起,
// 同音词的先后顺序就丢了。+1 后既保持相对顺序又不会被覆盖。
Rank = w.Rank + 1,
// FPT2 的 F1 字段即词频(用户选中次数,官方量级 1~140)。
// 原样映射:F1=0(从未选过)由构建器或上游的 DefaultWordRankGenerator
// 统一归为 1。
// 早期这里写 +1(为躲开上游把 Rank==0 覆盖成 1),但那样每轮
// 「导入→导出」都会把所有词频抬 1,反复往返后全部撞到上限、
// 同音词的相对顺序丢失。现在构建器自己会把 0 归为 1,无需偏移。
Rank = w.Rank,
CodeType = CodeType.Pinyin,
Code = code,
});
Expand Down
30 changes: 19 additions & 11 deletions src/ImeWlConverter.Formats/GboardBinary/GboardDictionaryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ internal sealed class GboardWord
/// 槽号 <c>slot = (val>>1) | (slot_hi&lt;&lt;15)</c>(23 位)</item>
/// <item>DA-trie 终端节点 <c>base</c> = 6 × F1 行号</item>
/// <item>词表按 KEY 路径字节序排序</item>
/// <item>KEY 的 F1 值全局唯一(同音组平局会导致点选候选时死循环)</item>
/// <item>KEY 的 F1 = 词频(用户选中次数, 官方量级 1~140); 平局正常</item>
/// </list>
/// </summary>
internal static class GboardDictionaryBuilder
Expand Down Expand Up @@ -336,16 +336,24 @@ private static byte[] Serialize(List<Item> items, ICollection<string>? warnings)
{
var n = items.Count;

// ---- KEY 的 F1: 按 Rank 降序(稳定)分配全局唯一递降值 ----
// 同音组内若 F1 平局, Gboard 调整候选顺序时会死循环
var rankOrder = Enumerable.Range(0, n).ToArray();
Array.Sort(rankOrder, (x, y) =>
{
var c = items[y].Rank.CompareTo(items[x].Rank);
return c != 0 ? c : x.CompareTo(y);
});
for (var pos = 0; pos < n; pos++)
items[rankOrder[pos]].F1 = n - pos;
// ---- KEY 的 F1 = 词频 ----
// F1 就是 Gboard 记的「用户选中次数」:官方词典里绝大多数词是 1、最高约 140,
// 用户每选一次 +1。所以导入时必须把词频保持在**这个量级**。
// 早期版本按名次摊成 1..n 的大跨度,实测把 亟需 写成 F1=7775,
// 而用户反复选中的 继续 才涨到 33 —— 候选顺序被永久冻死,再也纠不回来。
// 平局是正常的:官方词典里 8619 个词同为 F1=1。
var maxRank = 0;
foreach (var it in items)
if (it.Rank > maxRank) maxRank = it.Rank;
foreach (var it in items)
{
if (it.Rank <= 0)
it.F1 = 1;
else if (maxRank <= 255)
it.F1 = it.Rank;
else
it.F1 = Math.Max(1, (int)Math.Round(255.0 * it.Rank / maxRank));
}

// ---- 插入顺序: [var1, val, var2..varN, key, key2..] ----
var insertOrder = new List<(int Item, string Name, byte[] Path)>();
Expand Down
62 changes: 62 additions & 0 deletions src/ImeWlConverterCoreTest/GboardBinaryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/

using System.IO;
using System;
using System.Collections.Generic;
using System.Linq;
using ImeWlConverter.Abstractions.Enums;
using ImeWlConverter.Abstractions.Models;
Expand Down Expand Up @@ -104,4 +106,64 @@ public void Export_AllEntriesWithoutPinyin_Throws()
Assert.Throws<System.InvalidOperationException>(
() => _exporter.ExportAsync(entries, ms).GetAwaiter().GetResult());
}

/// <summary>
/// KEY 的 F1 是「用户选中次数」,必须保持在 Gboard 的量级(官方 1~140)。
/// 早期版本按名次摊成 1..n 的大跨度,使导入词的 F1 远高于 Gboard 之后学到的
/// 词,候选顺序被永久冻死(实测:亟需 F1=7775 压过反复选中的 继续 F1=33)。
/// 这里断言 F1 就是词库给的词频,而不是按名次重排的值。
/// </summary>
[Fact]
public void Export_KeepsFrequenciesInGboardScale()
{
var entries = new[]
{
Entry("啊", "a") with { Rank = 1 },
Entry("爱", "ai") with { Rank = 5 },
Entry("安", "an") with { Rank = 100 },
};

using var ms = new MemoryStream();
_exporter.ExportAsync(entries, ms).GetAwaiter().GetResult();
var f1 = ReadKeyF1(ms.ToArray());

Assert.Equal(new[] { 1, 5, 100 }, f1.OrderBy(x => x).ToArray());
}

/// <summary>
/// 取出所有 KEY 条目的 F1。
/// FPT2 块 = "FixedPhraseTable\0" + 3B 对齐 + [u32 count] + count×14B,
/// 条目 = [keyNode:u32][valNode:u32][flag&lt;&lt;24 | F1:u32][D:u16]。
/// </summary>
private static List<int> ReadKeyF1(byte[] d)
{
var tag = System.Text.Encoding.ASCII.GetBytes("FixedPhraseTable");
var i1 = IndexOf(d, tag, 0);
var i2 = IndexOf(d, tag, i1 + 1);
Assert.True(i2 > 0, "找不到 FPT2 块");

var count = System.BitConverter.ToUInt32(d, i2 + 20);
var b = i2 + 28;
var result = new List<int>();
for (var e = 0; e < count; e++)
{
var flagsF1 = System.BitConverter.ToUInt32(d, b + e * 14 + 8);
var flag = flagsF1 >> 24;
if ((flag & 0x40) != 0 && (flag & 0x08) == 0)
result.Add((int)(flagsF1 & 0xFFFFFF));
}
return result;
}

private static int IndexOf(byte[] haystack, byte[] needle, int from)
{
for (var i = from; i <= haystack.Length - needle.Length; i++)
{
var ok = true;
for (var j = 0; j < needle.Length; j++)
if (haystack[i + j] != needle[j]) { ok = false; break; }
if (ok) return i;
}
return -1;
}
}
Loading