多年前研究過集保罕字集與 BIG5 造字,當時我用官方提供的編碼對照表(Map_code.txt)寫過一版 .NET 轉換函式。近期接到通報說程式跑出來的結果跟官方轉換工具不一樣,不知是不是操作方法有問題?,雖然懷疑程式是照著官方對照表轉換為何會有出入?莫非程式有 Bug?但既然官方有提供轉換工具,回歸官方解決方案才是正解。

防疫禁足放假被關在家有點無聊,又想起這事兒,便稍微研究集保提供的轉換工具,原來它是一支 Java 程式,可將 BIG5 檔案轉成 UTF-8,厲害的部分是能將造字對映成 Unicode 字元,也能反向將 UTF-8 轉回 BIG5。要將這功能整進 ASP.NET 或 .NET Console Application 也不是難事,用 Process 物件從 .NET 呼叫 Java 程式即可(參考:呼叫命令列程式並即時接收輸出),但每次執行建立新 Process 一定有額外效能損耗;另一個較嚴重的問題是 Java 轉換工具的速度比我預期慢,離我心中「適合高頻呼叫放心使用的好用元件」有段差距。想想這組轉換邏輯也不複雜,寫成原生版 .NET 程式庫還是較完美的解決方案。Google 了一下,好像沒人分享過 .NET 解法。說什麼 C# 也是程式語言界前五大門派,此刻怎能缺席?就讓我來開第一槍吧!

防疫期間沒法跑馬拉松,索性來場黑客松 - 來寫一個能取代 Java 版轉換工具的 .NET 版程式庫吧!

為了確保 .NET 版轉換程式與 Java 版執行結果一致,我想到一個驗證的好方法。轉換工具包裡有兩個檔案 Map_code.txt 與 Map_code_2.txt,前者包含所有集保造字:(如下圖,但要安裝官方工具裡的字型才能正確顯示)

後者則有所有造字所對映的 Unicode 字元:

這兩個檔案相當於「樂透包牌」,能涵蓋所有需要轉換的字元,將 BIG5 編碼的 Map_code.txt 轉成 UTF-8、將 UTF-8 編碼的 Map_code_2.txt 轉成 BIG5,就等同把所有字元都驗證過一遍。

我先寫一小段 PowerShell 呼叫 java -jar FontConvD.jar 完成上述兩個檔案的轉換並計時:

$sw = New-Object System.Diagnostics.Stopwatch
Get-Content .\test\B5U8.config
$sw.Start()
Start-Process java -ArgumentList "-jar FileConvertD.jar B5U8 .\test\B5U8.config `"`"" -Wait
$sw.Stop()
"B5U8 $($sw.ElapsedMilliseconds.ToString("n0"))ms"
Get-Content .\test\U8B5.config
$sw.Restart()
Start-Process java -ArgumentList "-jar FileConvertD.jar U8B5 .\test\U8B5.config" -Wait
$sw.Stop()
"U8B5 $($sw.ElapsedMilliseconds.ToString("n0"))ms"

Map_code.txt (big-src.txt) 2MB、Map_code_2.txt (utf8-src.txt) 2.9MB,兩個檔案的轉換分別花了 10 秒跟 13 秒,速度有點慢,看起來較適合批次作業,用在即時系統頻繁呼叫應該會有心理壓力。

我照樣以 Map_code_2.txt 為依據寫了以下的程式產生器,用 Hard-Coding 方式寫死 Dictionary 字元對照表進行轉換,Unicode 罕用字一個字由兩個字元組成,我用了 Dictionary<char, Dictionary<char, string>> 的小技巧搞定。

試跑對照後發現一些 Map_code_2.txt 沒說的眉角,例如:造字檔沒定義的 Unicode 罕用字要翻成 "■" 而不是 "??"、有大概 90 個字元(包含:賚祈禛熲媺...)雖然有造字,但 BIG5 本來就有,故 Unicode 轉 BIG5 時不需對映回造字、Unicode 轉 BIG5 時注音輕聲符號也要轉換、還有一個特例: F3 90 對映到 廍,但反向則是 BF DB B4 DF 對映 F3 90,這些差距也許就是文章開始提到官方轉換結果會不同的原因。

程式碼產生器範例如下:

        private static void GenClass()
        {
            Dictionary<string, string> map = new Dictionary<string, string>();
            List<string> undefChars = new List<string>();

            foreach (var line in File.ReadAllLines("Map_code_2.txt", Encoding.UTF8).Skip(4))
            {
                string convChar = line.Substring(0, line.IndexOf(" "));
                var m = Regex.Match(line.Substring(1, 16), "  0(?<u>[0-9A-F]{4})");
                if (m.Success)
                {
                    map.Add($@"\u{m.Groups["u"].Value}", convChar);
                }
                else undefChars.Add(convChar);
            }
            //注音輕聲符號
            map.Add("˙", "․");
            //額外修正
            map["\uE506"] = "熴";
            map["\uF390"] = "廍";
            map["\uF393"] = "枬";
            var sb = new StringBuilder();
            sb.AppendLine(@"
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

public class TdccEudcConverter 
{
");
            sb.AppendLine("\tstatic Dictionary<char, string> Eudc2Unicode = new Dictionary<char, string>()");
            sb.AppendLine("\t{");
            sb.AppendLine(
                string.Join(
                    ",\r\n",
                    map.OrderBy(o => o.Key).Select(o => $@"     ['{o.Key}'] = ""{o.Value}""").ToArray())
            );
            sb.AppendLine("\t};");
            sb.AppendLine("\tstatic HashSet<string> UndefChars = new HashSet<string>()");
            sb.AppendLine("\t{");
            sb.AppendLine(string.Join(
                    ",\r\n",
                    undefChars.Select(o => $@"     ""{o}""").ToArray())
            );
            sb.AppendLine("\t};");
            sb.AppendLine("\tstatic string NoConvChars = \"賚祈禛熲...略....\";");

            sb.AppendLine(@"
    static Dictionary<char, string> SngCharMapping = new Dictionary<char, string>();
    static Dictionary<char, Dictionary<char, string>> DblCharMapping = new Dictionary<char, Dictionary<char, string>>();

    static TdccEudcConverter()
    {
        var big5 = Encoding.GetEncoding(950);
        Func<string, string> convB5Bytes = (c) =>
            BitConverter.ToString(big5.GetBytes(c));
        var dict = new Dictionary<string, string>();
        foreach (var kv in Eudc2Unicode)
        {
            if (dict.ContainsKey(kv.Value) || NoConvChars.Contains(kv.Value))
            {
                continue;
            }
            dict.Add(kv.Value, kv.Key.ToString());
        }
        foreach (var uc in UndefChars)
        {

            if (!dict.ContainsKey(uc)) dict.Add(uc, ""■"");
        }
        foreach (var kv in dict)
        {
            if (kv.Key.Length == 1)
                SngCharMapping.Add(kv.Key[0], kv.Value);
            else if (kv.Key.Length == 2)
            {
                var c1 = kv.Key[0];
        var c2 = kv.Key[1];
                if (!DblCharMapping.ContainsKey(c1))
                    DblCharMapping.Add(c1, new Dictionary<char, string>());
                DblCharMapping[c1].Add(c2, kv.Value);
    }
            else
                throw new NotSupportedException();
}
//特例規則: F3 90 對映到 廍,但反向則是 BF DB B4 DF 對映 F3 90
DblCharMapping['\udbbf']['\udfb4'] = ""\uf390"";
    }

    public static string ConvEudcToUnicode(string src)
{
    var sb = new StringBuilder();
    foreach (char c in src)
    {
        if (Eudc2Unicode.ContainsKey(c))
            sb.Append(Eudc2Unicode[c]);
        else
            sb.Append(c);
    }
    return sb.ToString();
}

public static string ConvUnicodeToEudc(string src)
{
    var sb = new StringBuilder();
    var i = 0;
    while (i < src.Length)
    {
        var c = src[i];
        if (SngCharMapping.ContainsKey(c))
            sb.Append(SngCharMapping[c]);
        else if (DblCharMapping.ContainsKey(c))
        {
            if (i + 1 < src.Length && DblCharMapping[c].ContainsKey(src[i + 1]))
            {
                sb.Append(DblCharMapping[c][src[i + 1]]);
                i++;
            }
            else
                sb.Append(c);
        }
        else
            sb.Append(c);
        i++;
    }
    return sb.ToString();
}
");
            sb.AppendLine("}");
            File.WriteAllText("TdccEudcConverter.cs", sb.ToString(), Encoding.UTF8);
        }

執行產生的程式碼高達 28,479 行,寫死 Dictionary 對照表不是什麼高明技巧,但它簡單粗暴,用在程式產生器不增加維護成本,程式好寫且效能不差:(提醒:近三萬行的程式碼會拖累 Visual Studio IDE,讓 CPU 使用率飆高,建議把它編譯成 DLL 參照使用,不要直接將把 TdccEudcConverter.cs 加進專案)

編譯完成 DLL 大小約 514KB,我一樣寫 PowerShell 做測試,.NET 版轉換速度超快,第一次啟動初始化較慢約 0.5 秒,之後 2MB BIG5 轉 UTF8 不到 0.1 秒、2.9MB UTF8 轉 BIG5 耗時 0.2 秒,轉 UTF-8 比 Java 版快了約 100 倍、反向轉換快了約 65 倍,而用 git diff 驗證也與 Java 版的轉換結果一致,我非常滿意! (這個做法有個缺點 - 若未來對映表有變動,必須重新產生程式重新編譯,但我猜頻率不致太高在可接受範圍,而 Java 版速度慢也可能是邏輯較具彈性的代價)

就算系統不是用 .NET 開發,也值得考慮把它包成 WebAPI 或 EXE 作為 Java 版轉換工具的替代方案。什麼?你說你的系統不是跑 Windows?放心,改用 .NET Core/.NET 5 編譯就好了,跨平台問題早已不是 .NET 的限制。

程式碼我放上 Github 了,採用 MIT 授權,歡迎大家取用(免費、可改寫、可自由散佈、可商業使用,但正確性請自行驗證),算是我對開源社群的小小回饋,也邀請大家一起壯大 C# 門派。唯一的小要求是 - 請大家在使用前,先呼口號「C# 蒸蚌,.NET 好威呀!」 幫我的 Github 專案 點顆星星,讓我知道我的程式幫助到多少人:(老人更需要大家的互動與關懷 XD)

C# 真棒,.NET 好威呀!

Trying to write my .NET version to replace TDCC end user defined characters convertion tool.


Comments

# by Youlin

目前難字轉換為使用cns11643,集保要被取代

# by sa

C# 蒸蚌,.NET 好威呀!

# by eric lin

想請問 UTF8仍是造字如何顯示正常跟列印?? 用到本地端造字檔(購買某造字管理程式)

# by Jeffrey

to eric lin,若要採用造字管理程式解決方案,軟體廠商應該會提供使用說明才對。

Post a comment