好久好久沒有發文,這篇文章卡了好久,當然我不會承認是最近越來越偷懶。
今天要分享的是難字處理的心得,需求是要在網頁和 PDF 上顯示難字,因為程式和資料庫都支援 Unicode,所以最後決定使用全字庫處理,全字庫幾乎包含了所有的中文字,且只要在戶政登記過的姓名,就會加入其中,所以不太會有找不到字的問題。
這篇主要會介紹字元編碼的基本概念,我覺得基本概念非常重要,了解後處理難字時才會感到得心應手。
在電腦內的字元,其實是一串 0 和 1 組成的二進位資料,例如 1100001(97)
就代表了英文字母 a
,這個對應關係就是字元編碼 (Encoding),而這些編碼其實都是有設計過的,符合一定的科學合理和寫程式的方便性。
一組抽象字元編碼的集合就是字元集 (Charset),最早的字元集就是常聽到的 ASCII 碼,一個 ASCII 字元佔 1 byte 的空間,最高位用於奇偶校驗,所以只能存放 0 ~ 127 總共 128 個字,很明顯這種編碼方式可以存放的字數太少,無法用來表示中文字,所以才有 Big-5、Unicode、等等其他編碼方式用來顯示中文字。
學過組語的大大們應該很了解,不同 CPU 在處理多位元資料型態時,暫存器和記憶體存放資料的方式有兩種大端序
(big-endian) 和小端序
(little-endian)。
高位位元組
儲存在記憶體的低位地址
。低位位元組
儲存在記憶體的低位地址
。例如 "字" 的 Unicode 是 5B57,5B 為高位位元組,57 為低為位元位,如果開啟記事本後讀取到的順序是 5B 57,就是使用大端序儲存,反之就是小端序。
像我第一次在 C# 將 string 轉成 hex 時就覺得很困惑,為什麼 Unicode 是 5B57,而我看到的卻是 575B,後來才知道是因為 C# 和 C++ 一樣,位元組順序會隨著 CPU 架構而不同,在 Intel x86 x64 架構下會採用小端序,所以才會看到低位地址57放在前面,而 JAVA 因為 JVM 的原因沒有這種差異,都是採用大端序。
主機間不同的位元組順序,會造成網路通訊的困難,因此 TCP/IP 協議也規範了統一的位元組順序 網絡位元組序
,採用大端序,大部分的網路協定也都是使用大端序。
BIG-5、GBK、UTF-8 等編碼方式都是以單位元組為單位,所以沒有位元組順序的問題,不過 UTF-16 編碼是以兩個位元組為單位,所以需要知道資料使用的位元組順序才能解析資料。
Unocode 推薦判斷位元組順序的方式是 BOM (Byte Order Mark),其原理是在位元組流的開頭使用 FEFF
字元來識別位元組順序,如果在文件開頭讀到 FF
FE
就代表使用小端序,反之代表使用大端序。
有人一定會問,FF
FE
有沒有可能是沒使用 BOM 的大端序中的 FFFE
這個字元,這個問題其實在設計 BOM 時已經有考慮進去了,在 Unocode 中確保 FFFE
不會被指定為字元,因此也不該出現在資料中,所以 FF
FE
只能被解釋為小端序的 FEFF
,不可能是大端序的 FFFE
。
代碼頁的用途是用來區分不同的字元集編碼,告訴系統該使用哪種方式來解析文字編碼,例如記事本讀到 B1
F0
,那麼到底該解釋為 GBK 的 "别" 字,還是 BIG5 的 "梗" 字,這兩個字的編碼都是 B1F0,在沒有指定編碼訊息的情況下,Windows 會使用預設的代碼頁來解析文字編碼,Windows 的代碼頁也被稱為 ANSI
代碼頁,在 Windows 上預設代碼頁不能直接修改,必須透過選擇系統的 Locale 間接改變代碼頁。
代碼頁也用於不同編碼之間的轉換,在 Windows 2000 之後,Windows 統一採用 UTF-16 作為系統內部的字元編碼,代碼頁其實就是一張各國文字編碼和 Unocode 的對應表,Windows 就是透過此表實現不同編碼間的轉換。
不知道大家有沒有試過在不同編碼的文件中使用複製貼上,大家一定都有做過,只是可能不會特別去注意文件的編碼是否相同,例如在 GBK 文件中將 "文" 複製貼上到 BIG5 文件中,如果系統只是複製字元的編碼 CEC4 的話,那麼貼過去後看到的應該是 "恅",可是實際上看到的還是 "文",所以很明顯系統在這之間做了轉碼的動作,先將 GBK 對應到 Unicode,再將 Unicode 對應到 BIG5,所以字才沒有跑掉,大家無聊可以去試試看。
大家可以再試試上面的 "别",會發現貼過去怎麼變成了問號 ?
,為什麼沒有轉為繁體字 "別" 呢,因為在 Unicode 簡體字别和繁體字別其實是不同的兩個字,對應不同的碼位,所以無法直接轉換。那為什麼會變成問號呢,因為 BIG5 編碼中並沒有簡體字 "别",系統在轉換的過程中,會將代碼頁對應不到的字用問號 ?
取代,所以才會看到問號,而這個過程是不可逆的,轉換失敗後字碼就被改變了,無法再轉回原來的字。
CMAP 表的功能是將 字元編碼
和 字元點陣圖
對應起來,透過此表字型檔才能將文字由字元編碼轉換為字型圖檔顯示在螢幕上,不過一張 CMAP 表只能對應一種編碼方式,如果需要字型檔支援不同的編碼方案就需要多張對應表,因此 CMAP 表可以包含多個子表,每個子表對應一種編碼方案,字型檔可透過多種支援的編碼方案來查表,可以是 Unicode 或其他 Code Page,不過這裡我還沒有更深入研究,目前還只會用 Unicode 來查表,未來有機會再嘗試更多的編碼方案。
上面有提到不同 Code Page 轉換失敗後的字會變成問號,那字型檔找不到的字呢,這是我隨便找的一個大家系統上應該無法正常顯示的字 ?
,大家看到的應該是個長方形的方框,不過如果將這個字複製到 Word 然後選擇不同的字體,會發現每種字體的方框不太一樣,有些字體的方框內會多一個問號,而選擇標楷體會看不到方框,為什麼會這樣呢,原因在於字型檔中 索引 0
這個碼位,這個位置是一個代表 字元缺失
的碼位,在查表的過程中,對應不到的字會回傳 0,而打開字型檔可以看到索引 0 的位置就是一個長方形方框,所以才會看到難字變成長方形方框,不過這裡的方框和轉碼失敗後的問號不太一樣,方框只是字型檔無法顯示這個字,字元編碼並沒有被改變,因此還是可以複製貼上。
補充: 發現一個小秘密,儲存文章後,上面標紅色無法正常顯示的字變成了問號,表示 IT 邦的程式或資料庫不支援 Unicode,但看了一下網頁編碼是使用 UTF-8,所以我猜測應該是資料庫不支援,這樣這個字不能貼到 Word 測試了。
最後介紹 UTF-16 編碼,也是我誤解最深的編碼方式,在開始研究全字庫前我的第一個疑問是,C# 的字串到底可不可以儲存 4 個 byte 的 Unicode,因為我知道 C# 的字元 char 和字串 string 底層是使用 UTF-16 編碼,而 UTF-16 是用 2 個 byte 儲存資料,可是全字庫的字碼會用到 4 個 byte,那 C# 有辦法處理這些字嗎?
我相信很多人會和我有相同的疑問,因為我們都誤會了 UTF-16,其實 UTF-16 和 UTF-8 一樣是可變長度的編碼方式,UCS-2 才是固定 2 個 byte 的 Unicode 編碼方式。
UTF-16 可以儲存 U+0000 至 U+10FFFF 之間的字碼,U+FFFF 以下的字碼以 2 個 byte 儲存,而 U+10000 以上的字碼,會被拆成兩個介於 D800 至 DFFF 之間的整數,第一個被稱為 前導代理
(lead surrogates),介於 D800 至 DBFF 之間,第二個被稱為 後尾代理
(trail surrogates),介於 DC00 至 DFFF 之間,UTF-16 就是利用這兩個代理對來表示 FFFF 之外,其他輔助平面的文字。
Unicode 標準規定 U+D800 至 U+DFFF 之間的碼位不對應任何字元,因此代理一定是成對出現,共同表達一個字,單一出現的代理一定是資料發生錯誤。且因為前導代理、後尾代理、U+FFFF 以下的字碼,三者互不重疊,也表示 UTF-16 編碼是 自同步
的,自同步的意思是,可通過僅檢查一個字元,就可判斷下一個字元的起始碼元,不用從頭開始分析編碼,例如在讀取過程中,看到一個前導代理,那麼就可以推測下一個出現的一定是後尾代理。
下表為代理對拆解過程:
順序 | 動作 | 範例 |
---|---|---|
1 | 介於 U+10000 到 U+10FFFF 的字元 | 10000 ~ 10FFFF |
2 | 將字碼減去 10000 | 0 ~ FFFFF |
3 | 此整數的值可用 20 個位元表示 | xxxxxxxxxxxxxxxxxxxx |
4 | 將 20 個位元拆成前後兩部分 | xxxxxxxxxx | xxxxxxxxxx |
5 | 將前半段的值加上 D800 | 110110xxxxxxxxxx |
6 | 將後半段的值加上 DC00 | 110111xxxxxxxxxx |
7 | 將前後合併以16進位表示 | XXXX 前導代理 | XXXX 後尾代理 |
遇到難字時該如何找字呢,首先到 全字庫網站,裡面有非常完整的查詢功能,可以很方便找到需要的字,在程式和資料庫都支援 Unicode 的情況下,找到的字可以直接使用。
如果需要用到字型檔 (例如 PDF 或網頁顯示),可以到 政府資料開放平台 下載全字庫的字型檔,格式是 TTF。
全字庫字型檔無法直接放在網頁上,我有試放不過會因為檔案太大被 Chrome 檔掉,字型檔大小好像不能超過 30 MB 的樣子,而完整的全字庫 100 多 MB,不過就算可以掛 30 MB 的字型檔,也會造成網頁反應速度緩慢,下載字型檔的期間會像當掉一樣。
網頁顯示主要有兩種做法:
兩種作法各有優缺點,視情況選用。
這次程式比較片段,會用到 SharpFont
可用 Nuget 安裝。
public static class StringExtensions
{
public static IEnumerable<int> AsCodePoints(this string s)
{
for (int i = 0; i < s.Length; i++)
{
yield return char.ConvertToUtf32(s, i);
if (char.IsHighSurrogate(s, i))
{
i++;
}
}
}
}
var str = "a2三";
var codePoints = str.AsCodePoints().ToArray();
//[0] 97
//[1] 50
//[2] 19977
//字型檔路徑
var path = "msjh.ttf";
var glyphTypeface = new GlyphTypeface(new Uri(path,UriKind.Absolute));
//字元編碼的整數值
var codePoint = 97; //a
try
{
//找到字體回傳圖像索引
var index = glyphTypeface.CharacterToGlyphMap[codePoint];
}
catch(Exception)
{
//找不到字體
}
//字型檔路徑
var path = "msjh.ttf";
//新字型檔存放路徑
var newPath = "new.ttf";
//要取出 a 和 2 的字體
var codePoints = new ushort[] { 97, 50 };
var glyphTypeface = new GlyphTypeface(new Uri(path,UriKind.Absolute));
//傳回新字型檔的 byte[]
var bytes = glyphTypeface.ComputeSubset(codePoints);
//另存檔案
using (var fs = new FileStream(newPath, FileMode.Create)
{
fs.Write(bytes, 0, bytes.Length);
}
以上是我對難字的認識和一些處理上的心得,小弟是新手,如果有錯誤再麻煩各位大大留言告知我,感謝大家。
UCS-2 UCS-4 中文字符编码 TTF字库之间的关系
維基百科 - 位元組順序
大端和小端
谈谈Unicode编码,简要解释UCS、UTF、BMP、BOM等名词
刨根究底字符编码之七——ANSI编码与代码页(Code Page)
编码介绍 ASCII与Unicode, codepage, utf-8
字元編碼與程式設計(五):Unicode的編碼
維基百科 - UTF-16
Using unicode characters bigger than 2 bytes with .Net
哇!終於等到大大發文了XD,
醞釀了那麼久果然很精華,不只說明了處理方式,
連字元的各種編碼都很清楚解釋!
好像在上一堂很深的計概課
偷懶了好久。
您好,您有考慮過使用css配合woff嗎?因為,我是使用這種方式處理,把eudc.tte透過軟體轉成woff,再用css
感謝大大分享,字型使用全字庫,是想避免自行造字會有字碼不統一的問題,所以不能用 eudc.tte。