以往程式要處理中文是相當麻煩的事情,就以斷詞斷句為例,英文單字都是以空格分開,很好分解,標點符號也不多,一個句子一定以大寫開頭、句號(”.”)結尾,句型或語法規則很清楚,幾乎所有程式語言都會內建處理英文字串的相關函式。

中文就複雜多了,古文、散文語法差異很大,標點符號多到不行,行文方向與句讀規則幾乎是隨心所欲,再加上現代社會經常出現中文、英文、數字、全形、半形混合的情況,光是要做字串分解就是一大難題。

還好 Swift 以萬國碼(Unicode)為基礎,萬國碼的設計中,已經考慮各國文字的異同,對每個字元都仔細加以分類、標註屬性,而 Swift 所有處理字串的函式,不只考慮英文,也同時考慮萬國碼所涵蓋的字元,這對我們程式設計處理中文非常有幫助。

上一節分解字串的物件方法 components(separatedBy: 分隔符號),處理中文也非常好用,它會依照參數「分隔符號」將字串分解,傳回分解後的字串陣列。其中參數「分隔符號」是一個字元集合或字元陣列,若定義 let 分隔符號 = [“,”, “。”, “;”],就會以中文全形的標點符號(逗號、句號、分號)做為分解字串的分隔符號。

那我們是不是可以定義一個包含所有「中文標點符號」的字元集,來做為斷句的分隔符號呢?其實不需要,因為Swift已經幫我們預設好了。

任何萬國碼字元都可以當作分隔符號,Swift 預先定義了一些「字元集」(CharacterSet),例如上節範例4-3a所用的 .newlines 就是其中之一,注意 newlines 是複數,因為萬國碼裡面當作「換行」的控制字元有好幾個,不只是“\n”。

以下是Swift預先定義,可做為分隔符號的字元集(CharacterSet):

預設「字元集」名稱 說明 包含字元數
.newlines 換行字元集(控制字元) 5個控制字元(0x0A, 0x0D, 0x85, 0x2028, 0x2029)
.whitespaces 各國空白字元集 17個屬於Unicode Zs類別的字元
.whitespacesAndNewlines 空白+換行 22個字元
.punctuationCharacters 各國標點符號 819個Unicode P*類別字元
.lowercaseLetters 各國小寫字母 2,227個Unicode Ll類別字元
.uppercaseLetters 各國大寫字母 1831個Unicode Lu類別字元
.decimalDigits 各國十進位數字 660個Unicode Nd類別字元
.symbols 所有符號 7,741個Unicode S*類別字元

下面範例,我們利用定義好的 .punctuationCharacters 字元集,將一篇文章依照標點符號來斷句,取得一句句不含標點的文字,我們以古典小說「西遊記第一回」來測試看看。

// 4-3b 文字斷句
// Created by Heman, 2022/03/30
import PlaygroundSupport
import SwiftUI

let 西遊記第一回 = """
    第一回    靈根育孕源流出 心性修持大道生
    
      詩曰:
        混沌未分天地亂,茫茫渺渺無人見。
        自從盤古破鴻濛,開闢從茲清濁辨。
        覆載群生仰至仁,發明萬物皆成善。
        欲知造化會元功,須看西遊釋厄傳。
      蓋聞天地之數,有十二萬九千六百歲為一元。將一元分為十二會,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每會該一萬八百歲。且就一日而論:子時得陽氣,而丑則雞鳴﹔寅不通光,而卯則日出﹔辰時食後,而巳則挨排﹔日午天中,而未則西蹉﹔申時晡,而日落酉,戌黃昏,而人定亥。譬於大數,若到戌會之終,則天地昏曚而萬物否矣。再去五千四百歲,交亥會之初,則當黑暗,而兩間人物俱無矣,故曰混沌。又五千四百歲,亥會將終,貞下起元,近子之會,而復逐漸開明。邵康節曰:「冬至子之半,天心無改移。一陽初動處,萬物未生時。」到此,天始有根。再五千四百歲,正當子會,輕清上騰,有日,有月,有星,有辰。日、月、星、辰,謂之四象。故曰,天開於子。又經五千四百歲,子會將終,近丑之會,而逐漸堅實。《易》曰:「大哉乾元!至哉坤元!萬物資生,乃順承天。」至此,地始凝結。再五千四百歲,正當丑會,重濁下凝,有水,有火,有山,有石,有土。水、火、山、石、土,謂之五形。故曰,地闢於丑。又經五千四百歲,丑會終而寅會之初,發生萬物。曆曰:「天氣下降,地氣上升﹔天地交合,群物皆生。」至此,天清地爽,陰陽交合。再五千四百歲,正當寅會,生人,生獸,生禽,正謂天地人,三才定位。故曰,人生於寅。
    """
var 斷句分解 = 西遊記第一回.components(separatedBy: .punctuationCharacters)
print("斷句分解結果:\\n\\(斷句分解)")

struct 小說: View {
    var 本文: String
    init(_ 字串參數: String) { 本文 = 字串參數 }
    @State var 顯示內容 = AttributedString("")
    @State var 分解結果: [String] = []
    @State var 索引 = 0
    let 定時器 = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()
    var body: some View {
        Text(顯示內容)
            .font(.system(.title3))
            .onAppear {
                let 初步斷句 = 本文.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))
                分解結果 = 初步斷句.filter { 句子 in
                    句子 != ""
                }
                索引 = 分解結果.startIndex
            }
            .onReceive(定時器) { _ in
                顯示內容 = AttributedString(本文)
                if let 範圍 = 顯示內容.range(of: 分解結果[索引]) {
                    print(範圍)
                    顯示內容[範圍].foregroundColor = .white
                    顯示內容[範圍].backgroundColor = .cyan
                    if 分解結果.index(after: 索引) == 分解結果.endIndex {
                        索引 = 分解結果.startIndex
                    } else {
                        索引 = 分解結果.index(after: 索引)
                    }
                }
            }
    }
}

PlaygroundPage.current.setLiveView(小說(西遊記第一回))

程式一開始,我們先測試 .components(separatedBy: .punctuationCharacters) 中文斷句是否正常:

var 斷句分解 = 西遊記第一回.components(separatedBy: .punctuationCharacters)
print("斷句分解結果:\\n\\(斷句分解)")

檢查控制台輸出: