繪圖動畫基礎-語法篇
-
字串分解:中文斷句
以往程式要處理中文是相當麻煩的事情,就以斷詞斷句為例,英文單字都是以空格分開,很好分解,標點符號也不多,一個句子一定以大寫開頭、句號(”.”)結尾,句型或語法規則很清楚,幾乎所有程式語言都會內建處理英文字串的相關函式。
中文就複雜多了,古文、散文語法差異很大,標點符號多到不行,行文方向與句讀規則幾乎是隨心所欲,再加上現代社會經常出現中文、英文、數字、全形、半形混合的情況,光是要做字串分解就是一大難題。
還好 Swift 以萬國碼(Unicode)為基礎,萬國碼的設計中,已經考慮各國文字的異同,對每個字元都仔細加以分類、標註屬性,而 Swift 所有處理字串的函式,不只考慮英文,也同時考慮萬國碼所涵蓋的字元,這對我們程式設計處理中文非常有幫助。
上一單元分解字串的物件方法 components(separatedBy: 分隔符號),處理中文也非常好用,它會依照參數「分隔符號」將字串分解,傳回分解後的字串陣列。其中參數「分隔符號」是一個字元集合或字元陣列,若定義 let 分隔符號 = [“,”, “。”, “;”],就會以中文全形的標點符號(逗號、句號、分號)做為分解字串的分隔符號。
那我們是不是可以定義一個包含所有「中文標點符號」的字元集,來做為斷句的分隔符號呢?其實不需要,因為Swift已經幫我們預設好了。
任何萬國碼字元都可以當作分隔符號,Swift 預先定義了一些「字元集」(CharacterSet),例如上節範例4-3a所用的 .newlines 就是其中之一,注意 newlines 是複數,因為萬國碼裡面當作「換行」的控制字元有好幾個,不只是“\n”。
以下是Swift預先定義,可做為分隔符號的字元集(CharacterSet):
下面範例,我們利用定義好的 .punctuationCharacters 字元集,將一篇文章依照標點符號來斷句,取得一句句不含標點的文字,我們以古典小說「西遊記第一回」來測試看看。
操作步驟如下:
選取畫面左方的「+」號新增電子書頁面。
將新增的電子書面頁命名為「(28)SwiftUI動畫-3語法篇」。
在「Main」模組中撰寫程式:
// 4-3b 文字斷句 // Created by Philip, Heman, Jean 2022/03/30 // Revised by Jean 2025/01/26 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(小說(西遊記第一回))
程式執行結果,如下圖。
檢查控制台輸出:
我們希望分解出各句純文字,但觀察這個結果可發現兩個問題:(1) 中文標點符號雖能有效斷句,但「空白和換行」因為不算標點符號被留下來,如何處理呢?(2) 出現兩個「空字串」,這是因為連續兩個標點符號所造成,如何移除空字串?
第一個問題,我們可以擴充分隔符號的「字元集」,除了標點符號,也增加空白與換行,程式碼如下:
var 斷句分解 = 西遊記第一回.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))
.punctuationCharacters 是一個字元的「集合」,「集合(Set)」與「陣列(Array)」的差別,是陣列元素有前後次序且元素可以重複,而集合不分次序且元素不會重複,集合可以透過 .union 與另一個集合「聯集」起來。
將分隔符號字集加入空白與換行後,斷句結果會增加更多空字串,因為只要連續兩個空格或空行,就會產生空字串,這是第二個要處理的問題。
要去掉陣列中的空字串,可以用陣列另外一個物件方法 .filter { },這個方法尾隨一個匿名函式,符合匿名函式 { } 裡面條件句的元素才會被保留下來,不符合條件句的元素就過濾掉:
斷句分解 = 斷句分解.filter { 句子 in 句子 != "" }
送入匿名函式的參數是陣列的每個元素「句子」,如果「句子」不是空字串,才保留下來。
測試過文字斷句的方法之後,就可以設計視圖物件,這次我們想透過參數將字串傳入視圖,而不是像上一節在視圖物件中使用全域變數,這部分程式碼如下:
struct 小說: View { var 本文: String init(_ 字串參數: String) { 本文 = 字串參數 } @State var 顯示內容 = AttributedString("") @State var 分解結果: [String] = [] var body: some View { Text(顯示內容) .font(.system(.title3)) .onAppear { let 初步斷句 = 本文.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines)) 分解結果 = 初步斷句.filter { 句子 in 句子 != "" } } } } PlaygroundPage.current.setLiveView(小說(西遊記第一回))
注意最後一行,我們使用物件實例時,是用「小說(西遊記第一回)」,而不是「小說()」,物件所需要的資料是透過參數傳進去,而不是抓全域變數,這樣的寫法是比較好的。
文字顯示的內容,一開始設為空字串,在 .onAppear 視圖出現的時候,尾隨的匿名函式只會被執行一次,所以我們在匿名函式中,將傳入的資料「本文」進行文字斷句,斷句後的結果指定給「分解結果」,這時「分解結果」是一個字串陣列。
接下來,仿照上一節的方法,用定時器 Timer 來做動畫效果,相關程式碼如下,需要增加一個狀態變數「索引」,指向「分解結果」裡面目前要反白顯示的句子。
struct 小說: View { @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(顯示內容) .onAppear { 索引 = 分解結果.startIndex } .onReceive(定時器) { _ in 顯示內容 = AttributedString(本文) if let 範圍 = 顯示內容.range(of: 分解結果[索引]) { print(範圍) 顯示內容[範圍].foregroundColor = .white 顯示內容[範圍].backgroundColor = .cyan if 分解結果.index(after: 索引) == 分解結果.endIndex { 索引 = 分解結果.startIndex } else { 索引 = 分解結果.index(after: 索引) } } } } }
定時器我們設定每隔0.3秒更新一次狀態變數,在 .onReceive 的匿名函式中,如何顯示文章內容呢?
在上一節,我們將斷行後的字串用VStack和ForEach重新組合起來,在這裡就不適用了,因為斷句後很難重新編排,所以我們顯示的是斷句前完整的「本文」:
顯示內容 = AttributedString(本文)
然後用第2課範例4-2a的方法,在「本文」中用 .range() 搜尋斷句後的詞句加以反白。
if let 範圍 = 顯示內容.range(of: 分解結果[索引]) { 顯示內容[範圍].foregroundColor = .white 顯示內容[範圍].backgroundColor = .cyan }