From 3da278ea2ca684c2f18099d956ca82af242ae9b1 Mon Sep 17 00:00:00 2001 From: Frederick <1814239758@qq.com> Date: Sun, 9 Aug 2026 19:47:26 +0800 Subject: [PATCH 1/3] feat: export Sogou Pinyin backup dictionaries --- src/IME WL Converter Win/Forms/MainForm.cs | 9 +- .../Contracts/IConversionPipeline.cs | 3 + .../Pipeline/ConversionPipeline.cs | 36 ++- .../SougouBin/SougouBinExporter.cs | 255 ++++++++++++++++++ .../SougouBin/SougouBinParser.cs | 2 +- .../PipelineBinaryExportTest.cs | 103 +++++++ .../SougouBinExportTest.cs | 90 +++++++ .../ViewModels/MainWindowViewModel.cs | 10 +- 8 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs create mode 100644 src/ImeWlConverterCoreTest/PipelineBinaryExportTest.cs create mode 100644 src/ImeWlConverterCoreTest/SougouBinExportTest.cs diff --git a/src/IME WL Converter Win/Forms/MainForm.cs b/src/IME WL Converter Win/Forms/MainForm.cs index 39671d61..1bddfc0f 100644 --- a/src/IME WL Converter Win/Forms/MainForm.cs +++ b/src/IME WL Converter Win/Forms/MainForm.cs @@ -56,6 +56,7 @@ public partial class MainForm : Form private int _convertedCount; private string? _exportContent; + private byte[]? _exportData; private CancellationTokenSource? _cts; @@ -382,6 +383,7 @@ private async void btnConvert_Click(object? sender, EventArgs e) richTextBox1.Clear(); _exportContent = null; + _exportData = null; _cts = new CancellationTokenSource(); SetConvertingState(true); @@ -426,6 +428,7 @@ private async void btnConvert_Click(object? sender, EventArgs e) { _convertedCount = result.Value.ExportedCount; _exportContent = result.Value.ExportContent; + _exportData = result.Value.ExportData; HandleConversionCompleted(result.Value); } else @@ -552,7 +555,11 @@ private void HandleConversionCompleted(ConversionResult result) if (saveFileDialog1.ShowDialog() == DialogResult.OK) { - if (_exportContent != null) + if (_exportData != null) + { + File.WriteAllBytes(saveFileDialog1.FileName, _exportData); + } + else if (_exportContent != null) { File.WriteAllText(saveFileDialog1.FileName, _exportContent); } diff --git a/src/ImeWlConverter.Abstractions/Contracts/IConversionPipeline.cs b/src/ImeWlConverter.Abstractions/Contracts/IConversionPipeline.cs index 43e79bc7..93a09c99 100644 --- a/src/ImeWlConverter.Abstractions/Contracts/IConversionPipeline.cs +++ b/src/ImeWlConverter.Abstractions/Contracts/IConversionPipeline.cs @@ -62,6 +62,9 @@ public sealed record ConversionResult /// Export content as string (for GUI display). Only populated when OutputStream is used. public string? ExportContent { get; init; } + /// Export data for binary formats. Only populated when OutputStream is used. + public byte[]? ExportData { get; init; } + /// Accumulated error messages from individual file processing. public string? ErrorMessages { get; init; } } diff --git a/src/ImeWlConverter.Core/Pipeline/ConversionPipeline.cs b/src/ImeWlConverter.Core/Pipeline/ConversionPipeline.cs index 56059d1f..235cfff1 100644 --- a/src/ImeWlConverter.Core/Pipeline/ConversionPipeline.cs +++ b/src/ImeWlConverter.Core/Pipeline/ConversionPipeline.cs @@ -141,20 +141,37 @@ e.Code is not null && progress?.Report(new ProgressInfo(exportedCount, exportedCount, $"正在导出 {exportedCount} 条词条...")); string? exportContent = null; + byte[]? exportData = null; if (request.OutputStream is not null) { - // GUI mode: write to provided stream, capture content - await exporter.ExportAsync(entries, request.OutputStream, request.Options.Export, ct); - request.OutputStream.Position = 0; - using var reader = new StreamReader(request.OutputStream, exporter.OutputEncoding, leaveOpen: true); - exportContent = await reader.ReadToEndAsync(ct); + // GUI mode: write to provided stream and retain the exact output for saving. + var exportResult = await exporter.ExportAsync(entries, request.OutputStream, request.Options.Export, ct); + exportedCount = exportResult.EntryCount; + filteredCount = importedCount - exportedCount; + if (request.OutputStream.CanSeek && request.OutputStream.CanRead) + { + request.OutputStream.Position = 0; + if (exporter.Metadata.IsBinary) + { + using var copy = new MemoryStream(); + await request.OutputStream.CopyToAsync(copy, ct); + exportData = copy.ToArray(); + } + else + { + using var reader = new StreamReader(request.OutputStream, exporter.OutputEncoding, leaveOpen: true); + exportContent = await reader.ReadToEndAsync(ct); + } + } } else if (request.OutputPath is not null) { // CLI/file mode: write directly to file using var outputStream = File.Create(request.OutputPath); - await exporter.ExportAsync(entries, outputStream, request.Options.Export, ct); + var exportResult = await exporter.ExportAsync(entries, outputStream, request.Options.Export, ct); + exportedCount = exportResult.EntryCount; + filteredCount = importedCount - exportedCount; } var errorStr = errors.Length > 0 ? errors.ToString() : null; @@ -165,6 +182,7 @@ e.Code is not null && ExportedCount = exportedCount, FilteredCount = filteredCount, ExportContent = exportContent, + ExportData = exportData, ErrorMessages = errorStr }); } @@ -212,11 +230,11 @@ private async Task> ExecutePerFileAsync( var outputFile = Path.Combine( request.OutputDirectory ?? ".", - Path.GetFileNameWithoutExtension(file) + ".txt"); + Path.GetFileNameWithoutExtension(file) + exporter.Metadata.FileExtension); using var outStream = File.Create(outputFile); - await exporter.ExportAsync(fileEntries, outStream, request.Options.Export, ct); + var exportResult = await exporter.ExportAsync(fileEntries, outStream, request.Options.Export, ct); - totalConverted += fileEntries.Count; + totalConverted += exportResult.EntryCount; progress?.Report(new ProgressInfo(i + 1, totalFiles, $"已导出: {outputFile}")); } catch (OperationCanceledException) { throw; } diff --git a/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs b/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs new file mode 100644 index 00000000..64bb5f1a --- /dev/null +++ b/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs @@ -0,0 +1,255 @@ +namespace ImeWlConverter.Formats.SougouBin; + +using System.Buffers.Binary; +using System.Text; +using ImeWlConverter.Abstractions; +using ImeWlConverter.Abstractions.Contracts; +using ImeWlConverter.Abstractions.Models; +using ImeWlConverter.Abstractions.Options; +using ImeWlConverter.Abstractions.Results; + +/// +/// Writes Sougou Pinyin SGPU backup dictionaries. +/// +[FormatPlugin("sgpybin", "搜狗拼音备份词库bin", 30, IsBinary = true, FileExtension = ".bin")] +public sealed partial class SougouBinExporter : IFormatExporter +{ + private const int HeaderSize = 0x8C; + private const int DefaultIndexCapacity = 10_000; + private const int DefaultDictionaryCapacity = 400_000; + + public async Task ExportAsync( + IReadOnlyList entries, + Stream output, + ExportOptions? options = null, + CancellationToken ct = default) + { + var pendingRecords = new List(entries.Count); + var pinyinToIndex = BuildPinyinIndex(); + + foreach (var entry in entries) + { + ct.ThrowIfCancellationRequested(); + + if (TryCreateRecord(entry, pinyinToIndex, out var record)) + pendingRecords.Add(record); + } + + var seenKeys = new HashSet(StringComparer.Ordinal); + var records = new List(pendingRecords.Count); + for (var i = 0; i < pendingRecords.Count; i++) + { + var pending = pendingRecords[i]; + var key = pending.Word + "\0" + string.Join(',', pending.PinyinIndices); + var isDuplicate = !seenKeys.Add(key); + var id = checked((ushort)(i + 2)); + records.Add(BuildRecord(pending, id, isDuplicate)); + } + + var dictionaryUsed = records.Sum(record => record.Data.Length); + var indexCapacity = RoundUp(Math.Max(DefaultIndexCapacity, records.Count), DefaultIndexCapacity); + var dictionaryCapacity = RoundUp( + Math.Max(DefaultDictionaryCapacity, dictionaryUsed), + DefaultDictionaryCapacity); + var dictionaryBegin = checked(HeaderSize + indexCapacity * sizeof(uint)); + var data = new byte[checked(dictionaryBegin + dictionaryCapacity)]; + + WriteHeader(data, records, indexCapacity, dictionaryBegin, dictionaryCapacity, dictionaryUsed); + + var dictionaryOffset = 0; + foreach (var record in records) + { + record.DictionaryOffset = dictionaryOffset; + record.Data.CopyTo(data, dictionaryBegin + dictionaryOffset); + dictionaryOffset += record.Data.Length; + } + + var logicalIndex = records + .OrderBy(record => record, SougouRecordComparer.Instance) + .ToList(); + for (var i = 0; i < logicalIndex.Count; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian( + data.AsSpan(HeaderSize + i * sizeof(uint), sizeof(uint)), + checked((uint)logicalIndex[i].DictionaryOffset)); + } + + await output.WriteAsync(data, ct); + await output.FlushAsync(ct); + + return new ExportResult + { + EntryCount = records.Count, + ErrorCount = entries.Count - records.Count + }; + } + + private static Dictionary BuildPinyinIndex() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < SougouBinParser.PinyinData.Length; i++) + result.TryAdd(SougouBinParser.PinyinData[i], checked((ushort)i)); + + return result; + } + + private static bool TryCreateRecord( + WordEntry entry, + IReadOnlyDictionary pinyinToIndex, + out PendingRecord record) + { + record = default!; + if (string.IsNullOrWhiteSpace(entry.Word) || entry.Code?.Segments.Count is not > 0) + return false; + + var pinyinIndices = new ushort[entry.Code.Segments.Count]; + for (var i = 0; i < entry.Code.Segments.Count; i++) + { + var segment = entry.Code.Segments[i]; + if (segment.Count == 0 || !pinyinToIndex.TryGetValue(NormalizePinyin(segment[0]), out pinyinIndices[i])) + return false; + } + + var wordBytes = Encoding.Unicode.GetBytes(entry.Word); + var pinyinByteCount = checked(pinyinIndices.Length * sizeof(ushort)); + if (wordBytes.Length > ushort.MaxValue || pinyinByteCount > ushort.MaxValue) + return false; + + record = new PendingRecord( + entry.Word, + pinyinIndices, + wordBytes, + Math.Clamp(entry.Rank, 0, ushort.MaxValue)); + return true; + } + + private static SougouRecord BuildRecord(PendingRecord pending, ushort id, bool isDuplicate) + { + var pinyinByteCount = checked(pending.PinyinIndices.Length * sizeof(ushort)); + var trailingData = isDuplicate + ? BuildPinyinTail(pending.PinyinIndices) + : new byte[] { 0x02, 0x00, 0xA0, 0x03 }; + var data = new byte[checked(15 + pinyinByteCount + pending.WordBytes.Length + trailingData.Length)]; + + BinaryPrimitives.WriteUInt16LittleEndian(data, checked((ushort)pending.Rank)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(2), id); + data[4] = 0; + data[5] = 0; + data[6] = 1; + data[7] = 0; + data[8] = checked((byte)Math.Min(pending.PinyinIndices.Length, byte.MaxValue)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(9), checked((ushort)pinyinByteCount)); + + for (var i = 0; i < pending.PinyinIndices.Length; i++) + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(11 + i * sizeof(ushort)), pending.PinyinIndices[i]); + + var wordInfoOffset = 11 + pinyinByteCount; + BinaryPrimitives.WriteUInt16LittleEndian( + data.AsSpan(wordInfoOffset), + checked((ushort)(sizeof(ushort) + pending.WordBytes.Length + trailingData.Length))); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(wordInfoOffset + 2), checked((ushort)pending.WordBytes.Length)); + pending.WordBytes.CopyTo(data, wordInfoOffset + 4); + trailingData.CopyTo(data, wordInfoOffset + 4 + pending.WordBytes.Length); + + return new SougouRecord(pending.Word, pending.PinyinIndices, id, data); + } + + private static byte[] BuildPinyinTail(IReadOnlyList pinyinIndices) + { + var data = new byte[checked(sizeof(ushort) + pinyinIndices.Count * sizeof(ushort))]; + BinaryPrimitives.WriteUInt16LittleEndian(data, checked((ushort)(pinyinIndices.Count * sizeof(ushort)))); + for (var i = 0; i < pinyinIndices.Count; i++) + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(sizeof(ushort) + i * sizeof(ushort)), pinyinIndices[i]); + return data; + } + + private static string NormalizePinyin(string value) + { + return value + .Trim() + .ToLowerInvariant() + .TrimEnd('0', '1', '2', '3', '4', '5') + .Replace("u:", "v", StringComparison.Ordinal) + .Replace('ü', 'v'); + } + + private static void WriteHeader( + byte[] data, + IReadOnlyList records, + int indexCapacity, + int dictionaryBegin, + int dictionaryCapacity, + int dictionaryUsed) + { + BinaryPrimitives.WriteUInt32LittleEndian(data, 0x55504753); // SGPU + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x04), 0x28); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x08), 0x0133A009); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x10), checked((uint)data.Length)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x14), 1); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x18), 16); + // Sogou validates this value when importing. It is the base marker plus + // dictionary data, per-entry bookkeeping, and the zero-frequency count. + var zeroRankCount = records.Count(record => record.Rank == 0); + var marker = checked(0x5691F359u + (uint)dictionaryUsed + (uint)Math.Max(records.Count - 1, 0) + (uint)zeroRankCount); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x20), marker); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x24), 84); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x28), checked((uint)(records.Count + 1))); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x30), checked((uint)records.Count(record => record.Rank > 0))); + BinaryPrimitives.WriteUInt32LittleEndian( + data.AsSpan(0x34), + checked((uint)records.Where(record => record.Rank > 0).Sum(record => (long)record.Id))); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x38), HeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x3C), checked((uint)(indexCapacity * sizeof(uint)))); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x40), checked((uint)records.Count)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x44), checked((uint)dictionaryBegin)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x48), checked((uint)dictionaryCapacity)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x4C), checked((uint)dictionaryUsed)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x50), 9); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x54), checked((uint)zeroRankCount)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x64), uint.MaxValue); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x68), 4); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x6C), uint.MaxValue); + } + + private static int RoundUp(int value, int unit) + { + return checked(((value + unit - 1) / unit) * unit); + } + + private sealed record PendingRecord(string Word, ushort[] PinyinIndices, byte[] WordBytes, int Rank); + + private sealed class SougouRecord(string word, ushort[] pinyinIndices, ushort id, byte[] data) + { + public string Word { get; } = word; + public ushort[] PinyinIndices { get; } = pinyinIndices; + public ushort Id { get; } = id; + public int Rank => BinaryPrimitives.ReadUInt16LittleEndian(Data); + public byte[] Data { get; } = data; + public int DictionaryOffset { get; set; } + } + + private sealed class SougouRecordComparer : IComparer + { + public static SougouRecordComparer Instance { get; } = new(); + + public int Compare(SougouRecord? x, SougouRecord? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + + var length = Math.Min(x.PinyinIndices.Length, y.PinyinIndices.Length); + for (var i = 0; i < length; i++) + { + var result = x.PinyinIndices[i].CompareTo(y.PinyinIndices[i]); + if (result != 0) return result; + } + + var countResult = x.PinyinIndices.Length.CompareTo(y.PinyinIndices.Length); + if (countResult != 0) return countResult; + + var wordResult = StringComparer.Ordinal.Compare(x.Word, y.Word); + return wordResult != 0 ? wordResult : y.Id.CompareTo(x.Id); + } + } +} diff --git a/src/ImeWlConverter.Formats/SougouBin/SougouBinParser.cs b/src/ImeWlConverter.Formats/SougouBin/SougouBinParser.cs index a2860900..5651798a 100644 --- a/src/ImeWlConverter.Formats/SougouBin/SougouBinParser.cs +++ b/src/ImeWlConverter.Formats/SougouBin/SougouBinParser.cs @@ -619,7 +619,7 @@ private static uint ReadUInt32(Stream fs) #region Pinyin data - private static readonly string[] PinyinData = + internal static readonly string[] PinyinData = { "a", "ai", "an", "ang", "ao", "ba", "bai", "ban", "bang", "bao", "bei", "ben", "beng", "bi", "bian", "biao", "bie", "bin", "bing", "bo", diff --git a/src/ImeWlConverterCoreTest/PipelineBinaryExportTest.cs b/src/ImeWlConverterCoreTest/PipelineBinaryExportTest.cs new file mode 100644 index 00000000..bf76a35b --- /dev/null +++ b/src/ImeWlConverterCoreTest/PipelineBinaryExportTest.cs @@ -0,0 +1,103 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ImeWlConverter.Abstractions.Contracts; +using ImeWlConverter.Abstractions.Models; +using ImeWlConverter.Abstractions.Options; +using ImeWlConverter.Abstractions.Results; +using ImeWlConverter.Core.Pipeline; +using Xunit; + +namespace ImeWlConverterCoreTest; + +public class PipelineBinaryExportTest +{ + [Fact] + public async Task ExecuteAsync_KeepsBinaryOutputAsBytes() + { + var inputPath = Path.GetTempFileName(); + try + { + var expected = new byte[] { 0x53, 0x47, 0x50, 0x55, 0xA0, 0x80, 0xFF }; + var pipeline = new ConversionPipeline( + [new TestImporter()], + [new TestBinaryExporter(expected)]); + using var output = new MemoryStream(); + + var result = await pipeline.ExecuteAsync(new ConversionRequest + { + InputFormatId = "test-input", + OutputFormatId = "test-binary", + InputPaths = [inputPath], + OutputStream = output + }); + + Assert.True(result.IsSuccess); + Assert.Equal(expected, result.Value.ExportData); + Assert.Null(result.Value.ExportContent); + } + finally + { + File.Delete(inputPath); + } + } + + [Fact] + public async Task ExecuteAsync_ReportsExporterEntryCount() + { + var inputPath = Path.GetTempFileName(); + try + { + var pipeline = new ConversionPipeline( + [new TestImporter()], + [new TestBinaryExporter([0x00], entryCount: 0)]); + using var output = new MemoryStream(); + + var result = await pipeline.ExecuteAsync(new ConversionRequest + { + InputFormatId = "test-input", + OutputFormatId = "test-binary", + InputPaths = [inputPath], + OutputStream = output + }); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value.ExportedCount); + Assert.Equal(1, result.Value.FilteredCount); + } + finally + { + File.Delete(inputPath); + } + } + + private sealed class TestImporter : IFormatImporter + { + public FormatMetadata Metadata { get; } = new("test-input", "Test input", 0, true, false); + + public Task ImportAsync(Stream input, ImportOptions? options = null, CancellationToken ct = default) => + Task.FromResult(new ImportResult + { + Entries = [new WordEntry { Word = "test" }] + }); + } + + private sealed class TestBinaryExporter(byte[] data, int? entryCount = null) : IFormatExporter + { + public FormatMetadata Metadata { get; } = new("test-binary", "Test binary", 0, false, true, true, ".bin"); + + public async Task ExportAsync( + IReadOnlyList entries, + Stream output, + ExportOptions? options = null, + CancellationToken ct = default) + { + await output.WriteAsync(data, ct); + return new ExportResult { EntryCount = entryCount ?? entries.Count }; + } + } +} diff --git a/src/ImeWlConverterCoreTest/SougouBinExportTest.cs b/src/ImeWlConverterCoreTest/SougouBinExportTest.cs new file mode 100644 index 00000000..5d5a9846 --- /dev/null +++ b/src/ImeWlConverterCoreTest/SougouBinExportTest.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ImeWlConverter.Abstractions.Enums; +using ImeWlConverter.Abstractions.Models; +using ImeWlConverter.Formats.SougouBin; +using Xunit; + +namespace ImeWlConverterCoreTest; + +public class SougouBinExportTest +{ + [Fact] + public async Task ExportThenImport_PreservesWordsCodesAndRanks() + { + var entries = new List + { + new() { Word = "\u6df1\u84dd\u8bcd\u5e93", Code = WordCode.FromSingle(new[] { "shen", "lan", "ci", "ku" }), Rank = 17, CodeType = CodeType.Pinyin }, + new() { Word = "\u8f6f\u4ef6\u6d4b\u8bd5", Code = WordCode.FromSingle(new[] { "ruan", "jian", "ce", "shi" }), Rank = 1, CodeType = CodeType.Pinyin }, + new() { Word = "\u5973\u7eff", Code = WordCode.FromSingle(new[] { "nv", "lv" }), Rank = 0, CodeType = CodeType.Pinyin } + }; + + var (result, data) = await Export(entries); + + Assert.Equal(entries.Count, result.EntryCount); + Assert.Equal(0, result.ErrorCount); + Assert.Equal(0x55504753u, BitConverter.ToUInt32(data, 0)); + Assert.Equal((uint)entries.Count, BitConverter.ToUInt32(data, 0x40)); + Assert.Equal((uint)0x8C, BitConverter.ToUInt32(data, 0x38)); + Assert.Equal((uint)data.Length, BitConverter.ToUInt32(data, 0x10)); + + using var stream = new MemoryStream(data); + var imported = (await new SougouBinImporter().ImportAsync(stream)).Entries; + + Assert.Equal(entries.Count, imported.Count); + foreach (var entry in entries) + { + var actual = Assert.Single(imported, item => item.Word == entry.Word); + Assert.Equal(entry.Code!.GetPrimaryCode(), actual.Code!.GetPrimaryCode()); + Assert.Equal(entry.Rank, actual.Rank); + } + } + + [Fact] + public async Task Export_SkipsEntriesWithoutSupportedPinyin() + { + var entries = new List + { + new() { Word = "\u6b63\u5e38", Code = WordCode.FromSingle(new[] { "zheng", "chang" }), Rank = 1 }, + new() { Word = "\u65e0\u6548", Code = WordCode.FromSingle(new[] { "not-pinyin" }), Rank = 1 }, + new() { Word = "\u7f3a\u5c11", Rank = 1 } + }; + + var (result, data) = await Export(entries); + + Assert.Equal(1, result.EntryCount); + Assert.Equal(2, result.ErrorCount); + Assert.Equal(1u, BitConverter.ToUInt32(data, 0x40)); + } + + [Fact] + public async Task Export_WritesSogouMarkerFromDictionarySizeAndZeroRanks() + { + var entries = new List + { + new() { Word = "\u4ec4\u963f\u7532", Code = WordCode.FromSingle(new[] { "ze", "a", "jia" }), Rank = 1 }, + new() { Word = "\u4ec4\u963f\u5047", Code = WordCode.FromSingle(new[] { "ze", "a", "jia" }), Rank = 0 } + }; + + var (_, data) = await Export(entries); + + var dictionarySize = BitConverter.ToUInt32(data, 0x4c); + var wordCount = BitConverter.ToUInt32(data, 0x40); + var zeroRankCount = BitConverter.ToUInt32(data, 0x54); + var expected = checked(0x5691F359u + dictionarySize + wordCount - 1 + zeroRankCount); + + Assert.Equal(expected, BitConverter.ToUInt32(data, 0x20)); + } + + private static async Task<(ImeWlConverter.Abstractions.Results.ExportResult Result, byte[] Data)> Export( + IReadOnlyList entries) + { + var exporter = new SougouBinExporter(); + using var stream = new MemoryStream(); + var result = await exporter.ExportAsync(entries, stream); + return (result, stream.ToArray()); + } +} diff --git a/src/ImeWlConverterMac/ViewModels/MainWindowViewModel.cs b/src/ImeWlConverterMac/ViewModels/MainWindowViewModel.cs index f1491ba8..ee5143df 100644 --- a/src/ImeWlConverterMac/ViewModels/MainWindowViewModel.cs +++ b/src/ImeWlConverterMac/ViewModels/MainWindowViewModel.cs @@ -41,6 +41,7 @@ public class MainWindowViewModel : ViewModelBase private FilterConfig _filterConfig = new(); private CodeGenerationOptions _codeGenOptions = new(); private string? _lastExportContent; + private byte[]? _lastExportData; private CancellationTokenSource? _cts; public MainWindowViewModel(IServiceProvider serviceProvider) @@ -306,6 +307,7 @@ private async Task ConvertAsync() Progress = 0; ResultText = ""; _lastExportContent = null; + _lastExportData = null; _cts = new CancellationTokenSource(); try @@ -352,6 +354,7 @@ private async Task ConvertAsync() if (result.IsSuccess) { _lastExportContent = result.Value.ExportContent; + _lastExportData = result.Value.ExportData; StatusMessage = $"转换完成,导入 {result.Value.ImportedCount} 条,导出 {result.Value.ExportedCount} 条"; if (_lastExportContent != null) @@ -575,7 +578,7 @@ private async void ShowMergeWL() private async Task ShowSaveDialog() { - if (string.IsNullOrEmpty(_lastExportContent)) return; + if (string.IsNullOrEmpty(_lastExportContent) && _lastExportData is null) return; try { @@ -607,7 +610,10 @@ private async Task ShowSaveDialog() if (file != null) { var filePath = file.Path.LocalPath; - await File.WriteAllTextAsync(filePath, _lastExportContent); + if (_lastExportData is not null) + await File.WriteAllBytesAsync(filePath, _lastExportData); + else + await File.WriteAllTextAsync(filePath, _lastExportContent!); StatusMessage = $"保存成功,词库路径:{filePath}"; } else From 11d813360acb51e4bcf8aba19d8da1ff39b23208 Mon Sep 17 00:00:00 2001 From: Frederick <1814239758@qq.com> Date: Sun, 9 Aug 2026 20:00:24 +0800 Subject: [PATCH 2/3] feat: support large Sogou backup dictionaries --- .../SougouBin/SougouBinExporter.cs | 52 ++++++++++---- .../SougouBinExportTest.cs | 72 +++++++++++++++++++ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs b/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs index 64bb5f1a..f02bb730 100644 --- a/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs +++ b/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs @@ -17,6 +17,9 @@ public sealed partial class SougouBinExporter : IFormatExporter private const int HeaderSize = 0x8C; private const int DefaultIndexCapacity = 10_000; private const int DefaultDictionaryCapacity = 400_000; + private const int LargeDictionaryCapacityUnit = 10_000; + private const int LargeDictionaryCapacityReserve = 90_000; + private const uint LargeDictionaryMarkerStride = 0x000B4AA0; public async Task ExportAsync( IReadOnlyList entries, @@ -35,6 +38,7 @@ public async Task ExportAsync( pendingRecords.Add(record); } + var isLargeDictionary = pendingRecords.Count > DefaultIndexCapacity; var seenKeys = new HashSet(StringComparer.Ordinal); var records = new List(pendingRecords.Count); for (var i = 0; i < pendingRecords.Count; i++) @@ -42,19 +46,28 @@ public async Task ExportAsync( var pending = pendingRecords[i]; var key = pending.Word + "\0" + string.Join(',', pending.PinyinIndices); var isDuplicate = !seenKeys.Add(key); - var id = checked((ushort)(i + 2)); + var id = isLargeDictionary ? (ushort)0 : checked((ushort)(i + 2)); records.Add(BuildRecord(pending, id, isDuplicate)); } var dictionaryUsed = records.Sum(record => record.Data.Length); var indexCapacity = RoundUp(Math.Max(DefaultIndexCapacity, records.Count), DefaultIndexCapacity); - var dictionaryCapacity = RoundUp( - Math.Max(DefaultDictionaryCapacity, dictionaryUsed), - DefaultDictionaryCapacity); + var dictionaryCapacity = isLargeDictionary + ? checked(RoundUp(dictionaryUsed, LargeDictionaryCapacityUnit) + LargeDictionaryCapacityReserve) + : RoundUp( + Math.Max(DefaultDictionaryCapacity, dictionaryUsed), + DefaultDictionaryCapacity); var dictionaryBegin = checked(HeaderSize + indexCapacity * sizeof(uint)); var data = new byte[checked(dictionaryBegin + dictionaryCapacity)]; - WriteHeader(data, records, indexCapacity, dictionaryBegin, dictionaryCapacity, dictionaryUsed); + WriteHeader( + data, + records, + indexCapacity, + dictionaryBegin, + dictionaryCapacity, + dictionaryUsed, + isLargeDictionary); var dictionaryOffset = 0; foreach (var record in records) @@ -179,7 +192,8 @@ private static void WriteHeader( int indexCapacity, int dictionaryBegin, int dictionaryCapacity, - int dictionaryUsed) + int dictionaryUsed, + bool isLargeDictionary) { BinaryPrimitives.WriteUInt32LittleEndian(data, 0x55504753); // SGPU BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x04), 0x28); @@ -187,17 +201,31 @@ private static void WriteHeader( BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x10), checked((uint)data.Length)); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x14), 1); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x18), 16); - // Sogou validates this value when importing. It is the base marker plus - // dictionary data, per-entry bookkeeping, and the zero-frequency count. + // Sogou validates this value when importing. Each index block after the + // first 10,000 records adds the allocator stride used by Sogou itself. var zeroRankCount = records.Count(record => record.Rank == 0); - var marker = checked(0x5691F359u + (uint)dictionaryUsed + (uint)Math.Max(records.Count - 1, 0) + (uint)zeroRankCount); + var additionalBlocks = isLargeDictionary + ? Math.Max(records.Count - 1, 0) / DefaultIndexCapacity + : 0; + var marker = checked( + 0x5691F359u + + (uint)dictionaryUsed + + (uint)Math.Max(records.Count - 1, 0) + + (uint)zeroRankCount + + checked((uint)additionalBlocks * LargeDictionaryMarkerStride)); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x20), marker); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x24), 84); - BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x28), checked((uint)(records.Count + 1))); - BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x30), checked((uint)records.Count(record => record.Rank > 0))); + BinaryPrimitives.WriteUInt32LittleEndian( + data.AsSpan(0x28), + isLargeDictionary ? 1u : checked((uint)(records.Count + 1))); + BinaryPrimitives.WriteUInt32LittleEndian( + data.AsSpan(0x30), + isLargeDictionary ? 0u : checked((uint)records.Count(record => record.Rank > 0))); BinaryPrimitives.WriteUInt32LittleEndian( data.AsSpan(0x34), - checked((uint)records.Where(record => record.Rank > 0).Sum(record => (long)record.Id))); + isLargeDictionary + ? 0u + : checked((uint)records.Where(record => record.Rank > 0).Sum(record => (long)record.Id))); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x38), HeaderSize); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x3C), checked((uint)(indexCapacity * sizeof(uint)))); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x40), checked((uint)records.Count)); diff --git a/src/ImeWlConverterCoreTest/SougouBinExportTest.cs b/src/ImeWlConverterCoreTest/SougouBinExportTest.cs index 5d5a9846..0af7ac31 100644 --- a/src/ImeWlConverterCoreTest/SougouBinExportTest.cs +++ b/src/ImeWlConverterCoreTest/SougouBinExportTest.cs @@ -79,6 +79,78 @@ public async Task Export_WritesSogouMarkerFromDictionarySizeAndZeroRanks() Assert.Equal(expected, BitConverter.ToUInt32(data, 0x20)); } + [Fact] + public async Task Export_AtTenThousandEntries_UsesSingleBlockLayout() + { + var (_, data) = await Export(CreateEntries(10_000)); + + var count = BitConverter.ToUInt32(data, 0x40); + var dictionaryBegin = BitConverter.ToUInt32(data, 0x44); + + Assert.Equal(10_000u, count); + Assert.Equal(10_001u, BitConverter.ToUInt32(data, 0x28)); + Assert.Equal(10_000u, BitConverter.ToUInt32(data, 0x30)); + Assert.NotEqual(0u, BitConverter.ToUInt32(data, 0x34)); + Assert.Equal((ushort)2, ReadRecordId(data, dictionaryBegin, 0)); + Assert.Equal((ushort)10_001, ReadRecordId(data, dictionaryBegin, (int)count - 1)); + } + + [Theory] + [InlineData(10_001, 1)] + [InlineData(20_000, 1)] + [InlineData(20_001, 2)] + [InlineData(30_000, 2)] + [InlineData(30_001, 3)] + [InlineData(40_000, 3)] + public async Task Export_LargeDictionary_UsesSogouBatchStorageLayout( + int entryCount, + uint expectedAdditionalBlocks) + { + var entries = CreateEntries(entryCount); + + var (_, data) = await Export(entries); + + var count = BitConverter.ToUInt32(data, 0x40); + var used = BitConverter.ToUInt32(data, 0x4c); + var expectedCapacity = checked(((used + 9_999u) / 10_000u) * 10_000u + 90_000u); + var expectedMarker = checked( + 0x5691F359u + + used + + count + - 1 + + expectedAdditionalBlocks * 0x000B4AA0u); + + Assert.Equal((uint)entryCount, count); + Assert.Equal(1u, BitConverter.ToUInt32(data, 0x28)); + Assert.Equal(0u, BitConverter.ToUInt32(data, 0x30)); + Assert.Equal(0u, BitConverter.ToUInt32(data, 0x34)); + Assert.Equal(expectedCapacity, BitConverter.ToUInt32(data, 0x48)); + Assert.Equal(expectedMarker, BitConverter.ToUInt32(data, 0x20)); + + var dictionaryBegin = BitConverter.ToUInt32(data, 0x44); + Assert.Equal((ushort)0, ReadRecordId(data, dictionaryBegin, 0)); + Assert.Equal((ushort)0, ReadRecordId(data, dictionaryBegin, (int)count / 2)); + Assert.Equal((ushort)0, ReadRecordId(data, dictionaryBegin, (int)count - 1)); + } + + private static List CreateEntries(int count) + { + return Enumerable.Range(0, count) + .Select(index => new WordEntry + { + Word = $"\u8bcd{index}", + Code = WordCode.FromSingle(new[] { "ci" }), + Rank = 1 + }) + .ToList(); + } + + private static ushort ReadRecordId(byte[] data, uint dictionaryBegin, int index) + { + var offset = BitConverter.ToUInt32(data, 0x8c + index * sizeof(uint)); + return BitConverter.ToUInt16(data, (int)dictionaryBegin + (int)offset + 2); + } + private static async Task<(ImeWlConverter.Abstractions.Results.ExportResult Result, byte[] Data)> Export( IReadOnlyList entries) { From 550d8a3c251a79d088930b5e449b466f23f49ef6 Mon Sep 17 00:00:00 2001 From: Frederick <1814239758@qq.com> Date: Sun, 9 Aug 2026 21:58:35 +0800 Subject: [PATCH 3/3] fix: match Sogou large dictionary allocator --- README.md | 4 +-- .../SougouBin/SougouBinExporter.cs | 36 ++++++++++++++----- .../SougouBinExportTest.cs | 23 ++++++------ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b3414cf5..6c117820 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ imewlconverter -i <输入格式> -o <输出格式> -O <输出文件> <输入文 |---------|------|-----------|:-------:|:-------:| | `scel` | 搜狗拼音细胞词库 | .scel | ✅ | ✅ | | `sgpy` | 搜狗拼音文本格式 | .txt | ✅ | ✅ | -| `sgpybin` | 搜狗拼音备份词库 | .bin | ✅ | ❌ | +| `sgpybin` | 搜狗拼音备份词库 | .bin | ✅ | ✅ | | `qqpy` | QQ 拼音文本格式 | .txt | ✅ | ✅ | | `qpyd` | QQ 拼音分类词库 | .qpyd | ✅ | ❌ | | `qcel` | QQ 拼音细胞词库 | .qcel | ✅ | ❌ | @@ -251,4 +251,4 @@ cd ../../tests/integration ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=studyzy/imewlconverter&type=Date)](https://star-history.com/#studyzy/imewlconverter&Date) \ No newline at end of file +[![Star History Chart](https://api.star-history.com/svg?repos=studyzy/imewlconverter&type=Date)](https://star-history.com/#studyzy/imewlconverter&Date) diff --git a/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs b/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs index f02bb730..20337be8 100644 --- a/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs +++ b/src/ImeWlConverter.Formats/SougouBin/SougouBinExporter.cs @@ -18,8 +18,10 @@ public sealed partial class SougouBinExporter : IFormatExporter private const int DefaultIndexCapacity = 10_000; private const int DefaultDictionaryCapacity = 400_000; private const int LargeDictionaryCapacityUnit = 10_000; - private const int LargeDictionaryCapacityReserve = 90_000; - private const uint LargeDictionaryMarkerStride = 0x000B4AA0; + private const int LargeDictionaryBytesPerIndexSlot = 31; + private const int LargeDictionaryReserve = 90_000; + private const int LargeDictionaryReserveGrowthPeriod = 4; + private const int LargeDictionaryReserveGrowth = 10_000; public async Task ExportAsync( IReadOnlyList entries, @@ -53,7 +55,9 @@ public async Task ExportAsync( var dictionaryUsed = records.Sum(record => record.Data.Length); var indexCapacity = RoundUp(Math.Max(DefaultIndexCapacity, records.Count), DefaultIndexCapacity); var dictionaryCapacity = isLargeDictionary - ? checked(RoundUp(dictionaryUsed, LargeDictionaryCapacityUnit) + LargeDictionaryCapacityReserve) + ? Math.Max( + CalculateLargeDictionaryCapacity(indexCapacity), + RoundUp(dictionaryUsed, LargeDictionaryCapacityUnit)) : RoundUp( Math.Max(DefaultDictionaryCapacity, dictionaryUsed), DefaultDictionaryCapacity); @@ -201,18 +205,20 @@ private static void WriteHeader( BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x10), checked((uint)data.Length)); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x14), 1); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x18), 16); - // Sogou validates this value when importing. Each index block after the - // first 10,000 records adds the allocator stride used by Sogou itself. + // Sogou validates this value when importing. Its allocator contributes + // two times the dictionary growth and three times the index growth. var zeroRankCount = records.Count(record => record.Rank == 0); - var additionalBlocks = isLargeDictionary - ? Math.Max(records.Count - 1, 0) / DefaultIndexCapacity - : 0; + var allocationMarker = isLargeDictionary + ? checked((uint)( + 2L * (dictionaryCapacity - DefaultDictionaryCapacity) + + 3L * ((long)indexCapacity * sizeof(uint) - DefaultIndexCapacity * sizeof(uint)))) + : 0u; var marker = checked( 0x5691F359u + (uint)dictionaryUsed + (uint)Math.Max(records.Count - 1, 0) + (uint)zeroRankCount - + checked((uint)additionalBlocks * LargeDictionaryMarkerStride)); + + allocationMarker); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x20), marker); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x24), 84); BinaryPrimitives.WriteUInt32LittleEndian( @@ -244,6 +250,18 @@ private static int RoundUp(int value, int unit) return checked(((value + unit - 1) / unit) * unit); } + private static int CalculateLargeDictionaryCapacity(int indexCapacity) + { + // Sogou allocates 31 bytes per index slot plus a 90 KB reserve. The + // reserve gains another 10 KB after every four 10,000-entry blocks. + var blockCount = indexCapacity / DefaultIndexCapacity; + var reserveGrowthCount = (blockCount - 1) / LargeDictionaryReserveGrowthPeriod; + return checked( + indexCapacity * LargeDictionaryBytesPerIndexSlot + + LargeDictionaryReserve + + reserveGrowthCount * LargeDictionaryReserveGrowth); + } + private sealed record PendingRecord(string Word, ushort[] PinyinIndices, byte[] WordBytes, int Rank); private sealed class SougouRecord(string word, ushort[] pinyinIndices, ushort id, byte[] data) diff --git a/src/ImeWlConverterCoreTest/SougouBinExportTest.cs b/src/ImeWlConverterCoreTest/SougouBinExportTest.cs index 0af7ac31..3e21eaf7 100644 --- a/src/ImeWlConverterCoreTest/SougouBinExportTest.cs +++ b/src/ImeWlConverterCoreTest/SougouBinExportTest.cs @@ -96,15 +96,17 @@ public async Task Export_AtTenThousandEntries_UsesSingleBlockLayout() } [Theory] - [InlineData(10_001, 1)] - [InlineData(20_000, 1)] - [InlineData(20_001, 2)] - [InlineData(30_000, 2)] - [InlineData(30_001, 3)] - [InlineData(40_000, 3)] - public async Task Export_LargeDictionary_UsesSogouBatchStorageLayout( + [InlineData(10_001, 710_000)] + [InlineData(20_000, 710_000)] + [InlineData(20_001, 1_020_000)] + [InlineData(30_000, 1_020_000)] + [InlineData(30_001, 1_330_000)] + [InlineData(40_000, 1_330_000)] + [InlineData(40_001, 1_650_000)] + [InlineData(48_000, 1_650_000)] + public async Task Export_LargeDictionary_UsesSogouAllocatorLayout( int entryCount, - uint expectedAdditionalBlocks) + uint expectedCapacity) { var entries = CreateEntries(entryCount); @@ -112,13 +114,14 @@ public async Task Export_LargeDictionary_UsesSogouBatchStorageLayout( var count = BitConverter.ToUInt32(data, 0x40); var used = BitConverter.ToUInt32(data, 0x4c); - var expectedCapacity = checked(((used + 9_999u) / 10_000u) * 10_000u + 90_000u); + var indexSize = BitConverter.ToUInt32(data, 0x3c); var expectedMarker = checked( 0x5691F359u + used + count - 1 - + expectedAdditionalBlocks * 0x000B4AA0u); + + checked(2u * (expectedCapacity - 400_000u)) + + checked(3u * (indexSize - 40_000u))); Assert.Equal((uint)entryCount, count); Assert.Equal(1u, BitConverter.ToUInt32(data, 0x28));