[Scala] Java / Scala 中含罕用漢字字串的處理。

我犯了所有寫程式的人都會犯的錯……

話說前一陣子在處理一批資料的時候,這才發現原來我對於 Java 程式裡的『字串』的認知,有著非常嚴重的誤解,甚至可以說是『錯的一塌糊塗』。

事情是這樣的,在單位內的系統所使用的交換資料是 Big5 編碼的 XML 文件,但眾所皆知的是,Big5 缺字很嚴重,例如陶『喆』和游鍚『堃』這兩個常見的例子。

幸好 Java 內的字串是 Unicode 編碼,所以是可以處理這兩個字的,而理論上我們只需要在將資料輸出成 XML 的時候,把這些 Big5 無法表示出的字元轉成 NCR 字串就好了。

例如說,把『喆』變成『喆』這個樣子就行了!這樣一來,瀏覽器會聰明地自動把這一串符號顯示成『喆』。

而在 Java / Scala 裡這件事也很簡單--喆是一個字元,而字元可以轉成整數,轉完之後就是 Unicode 裡面的 Code Point!

所以我們搞了一個類似下面的東西來做這樣的轉換,由於原來的程式是像沱屎的 Java,所以這邊我用 Scala 來示意。(謎之音:你就承認是你自己看不懂 Java 程式碼唄!)

// 如果該字元轉成 Big5 後變問號,就代表他不在 Big5 可表
// 視的字元裡
def notBig5 (ch: Char) = {
    ch.toString.getBytes("Big5")(0).toChar == '?' &&
    ch != '?'
}

def charToNCR (ch: Char) = notBig5(ch) match {
    case true  => "&#%d;" format (ch.toInt) // 如果不是 Big5 就轉成 NCR
    case false => ch.toString               // 不然維持原樣
}

// 針對 content 裡的每一個字元做 charToNCR,再把所有結果整合成一個字串
def normalizeString(content: String) = content.flatMap(charToNCR)

println (normalizeString("陶喆"))   // 陶喆
println (normalizeString("游鍚堃")) // 游鍚堃

但也很不幸的,這個函式很奇怪……當遇到了『言㐌』這種用了兩個 Java 字元來表示一個 Unicode 的字元時,你必須要在第一個 Java 字元上才能取出正確的 Unicode Code Point,當你用在第二個字元上的時候,他會給你一個你不需要的正整數。(謎之音:是你自己不會用唄!)

所以,最終的結論是--你必須在呼叫 codePointAt() 之前,忽略掉所有並不是 Unicode 字元開頭的 Java 字元(?)。

然後,你還要考慮到 TMD 不是每個人的電腦都有天殺的 Unicode 補完計劃,所以有的人看得到 Big5 檔案裡的日文,有的人看不到,於是你只好也把日文給轉成 NCR,於是最後程式就會變這個樣子:

一切都很好,世界運轉如常,我可以把 Big5 無法表示的字元正確地塞到系統的 XML 檔案中。

直到有一天單位內要處理一批佛典的資料,在這裡面出現了一個佛教用語--『𧦧懷』。

放心,我知道你看到的一定是一個奇怪的方塊,這是因為大部份的電腦字型裡根本沒有這個字,他看起來長得像(言㐌),《集韻》裡說這個字同『詑』

然後,我負則的系統就很帥氣地爆炸給我看了……因為我們的系統很聰明地將他轉成了『��懷』,是的,明明是兩個字的字串,他給我變成了三個字,然後想當然爾這是不合法的 NCR 字串,於是 Java 裡的 XML Parser 就爆炸給我看了。

經過不恥下問(?)後,我發現我犯了一個低級錯誤--我一直以為 Java 的字串是由字元陣列組成的,所以一個 Unicode 的字元一定對應一個 Java 的字元!

我完完全全地忘記了 Java 裡的 char 資料型態只佔了兩個 byte,也就是說他最多只能表示到 65535 個字,但 Unicode 裡絕對不只有 65535 個字啊,像上面『言㐌』的例子裡,這個字屬於 Unicode 中的 CJK ExtB 平面,他的 Code Point 編號是 162215,早就超過這個範圍了。

也就是說,因為實際上的 Java 字串是使用 UTF-16 編碼,一個 Unicode 字元有可能佔了兩個 Java 的字元,而我們寫出來的程式很帥氣地自動忽略了這個問題,我們犯了和『純文字 = ascii = 字元都是8個位元』幾乎一模一樣的問題。

幸好,在 Java 1.5 之後,String 類別就提供了 codePointAt() 這一系列的函式,可以幫忙計算字串裡某個字元的 Unicode Code Point。

但也很不幸的,這個函式很奇怪……當遇到了『言㐌』這種用了兩個 Java 字元來表示一個 Unicode 的字元時,你必須要在第一個 Java 字元上才能取出正確的 Unicode Code Point,當你用在第二個字元上的時候,他會給你一個你不需要的正整數。(謎之音:是你自己不會用唄!)

所以,最終的結論是--你必須在呼叫 codePointAt() 之前,忽略掉所有並不是 Unicode 字元開頭的 Java 字元(?)。

然後,你還要考慮到 TMD 不是每個人的電腦都有天殺的 Unicode 補完計劃,所以有的人看得到 Big5 檔案裡的日文,有的人看不到,於是你只好也把日文給轉成 NCR,於是最後程式就會變這個樣子:

object TextUtil
{
    import Character.UnicodeBlock

    /**
     *  將特定 Code Point 的 Unicode 字元轉為 Big5 可接受的形式
     *
     *  @param  codePoint   Unicode 字元的 Code Point
     *  @param              若該 codePoint 在 Big5 範圍內,則為僅包含該字元的字串,
     *                      否則將其轉為十進位 NCR 字串。
     */
    def normalizeCodePoint(codePoint: Int): String = {

        def isNotBig5 (codePoint: Int) = {
            codePoint > Char.MaxValue ||
            UnicodeBlock.of(codePoint) == UnicodeBlock.HIRAGANA ||
            UnicodeBlock.of(codePoint) == UnicodeBlock.KATAKANA ||
            UnicodeBlock.of(codePoint) == UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS ||
            (codePoint.toChar.toString.getBytes("Big5")(0) == '?' && codePoint != '?')
        }

        codePoint match {
            case _ if UnicodeBlock.of(codePoint) == UnicodeBlock.HIGH_SURROGATES => "?"
            case _ if isNotBig5(codePoint) => "&#%d;" format (codePoint)
            case _ => "%c" format(codePoint.toChar)
        }
    }

    /**
     *  將字串正規化為 Big5 可以表示的範圍
     *
     *  @param  string  要正規化的字元
     *  @return         將所有非 Big5 字元以十進位 NCR 取代後的字串
     */
    def normalizeString (string: String) = {

        /**
         *  檢查該字元是否為 Code Point 的開頭
         *
         *  說明:
         *
         *  在 Java 中,String 字串都是採用 UTF-16 編碼,在這個編碼的規則下,若
         *  某字元的 Unicode Code Point 在 2^32 = 65535 以下的話,會以一個 char
         *  來表示,該 char 之值即為 Unicode Code Point 之值。
         *
         *  但若該字元的 Unicode Code Point 之值在 2^32 = 65535 以上,則會採用
         *  兩個 char 來表示,在 String.toCharArray 時會佔用兩個 char,其中第一
         *  個 char 會落在 UnicodeBlock.HIGH_SURROGATES 範圍內。
         *
         *  請參照:http://0rz.tw/pRY9y
         *
         *  @param  currentIndex    字元索引
         *  @return                 若該字為 code point 則為 true,否則為 false
         */
        def isCodePointStart(currentIndex: Int): Boolean = currentIndex match {
            case 0 => true
            case _ => !string(currentIndex-1).isHighSurrogate
        }

        // 取得該字串裡所有的 Code Point
        val codePointVector = 
            for (i <- 0 until string.length if isCodePointStart(i)) yield string.codePointAt(i)

        codePointVector.map(normalizeCodePoint).mkString
    }
}

回響