深入理解 bpmfvs 讀音選擇工具:用 JavaScript、詞庫與 IVS 保存多音字讀音

最近我在研究如何替中文文章標註注音,找到 bpmfvs 注音 IVS 字型規格提供的讀音選擇工具。對這類工具來說,顯示注音只是其中一件事,更麻煩的是同一個字有不同讀音,而且選完之後,還要能複製到其他軟體,甚至更換字型。

我先從網頁程式往下追,確認它使用 JavaScript 與 jQuery,再沿著詞庫產生器與字型建置程式看了一遍。這才釐清整套工具的分工:JavaScript 根據詞庫協助選音,文字裡的異體字選擇子負責保存選擇,注音字型負責把結果畫出來。這三件事分開理解,很多看似神奇的行為就能解釋清楚。

這篇文章會從實際操作開始,接著拆解 IVS 編碼、詞庫格式、前端選音流程與 Ruby 資料產生器,最後整理我實測遇到的規則衝突與 Unicode 邊界。文中也會區分專案規格、目前程式的實作,以及還需要人工確認的地方。

以漢字活字、Unicode 選擇子與 JavaScript 表現注音字型選音機制的概念封面


先分清楚工具、文字與字型的責任

bpmfvs 這個儲存庫同時包含注音字型規格、字型建置程式、讀音資料與網頁工具。讀音選擇工具是其中的 ime/ 目錄,不能把整個專案都視為一個 JavaScript 應用程式。

層次負責什麼主要檔案
選音介面標記多音字、比對詞彙、讓使用者選音ime/index.html、ime/ime.js
文字編碼把選擇結果存成漢字與異體字選擇子的序列ime/ime.js 中的 vsbase 與選音事件
注音字型依文字序列選出漢字與注音組合的字形ime/BpmfZihiKaiStd-Regular.ttf、make_font.rb
資料準備整理讀音、產生瀏覽器使用的詞庫phonetic/make_ptable.rb、ime/gen_jsdb.rb

一般選音結果不是圖片,也不是在每個漢字旁插入一串可見的注音文字。選擇工具主要修改文字序列,實際看到的注音外觀由字型提供。因此,把結果換成不支援此規格的字型,可能只剩漢字;這不代表選音資訊一定已經消失。


先用一段文字操作一次

一般使用者可以從專案提供的線上入口開始,操作說明在 guide/README.md。如果要研究本文所述的程式版本,我建議直接在 checkout 中啟動本機 HTTP 服務:

git clone https://github.com/ButTaiwan/bpmfvs.git
cd bpmfvs
# 要重現本文版本時才需要這一步
# git checkout fa20c2bb5e2986856974f00c93a662d0805c92a0
python3 -m http.server 8765 --bind 127.0.0.1

接著開啟 http://127.0.0.1:8765/ime/,輸入以下文字:

會計和牛仔一起開會。
  1. 按下開始,工具會將文章轉成可逐字選擇的編輯畫面。
  2. 觀察已猜測的多音字,例如會計的會、牛仔的仔。
  3. 點選文字或下方箭頭切換多音字,再從候選列選擇讀音。
  4. 按下完成,工具會把結果放回文字框並選取,供使用者複製。
  5. 貼到目標軟體後,選用符合 bpmfvs 規格的注音字型,再確認顯示結果。

讀音選擇工具顯示會計和牛仔一起開會,並以底色標記多音、易猜錯及已猜測狀態

目前畫面的黃色表示多音字,粉紅色表示易猜錯,橘色表示已猜測,綠色表示已選擇,紫色表示特殊自選。這些底色是介面狀態;真正會隨純文字帶走的是選擇子,不是背景顏色。

README 提供直接開啟 HTML 的使用方式;我在本文用 HTTP 服務實測,讓字型、JavaScript 與字典 iframe 都由同一個來源載入,避免把本機檔案存取限制混進功能驗證。這個服務只提供靜態檔案,沒有在伺服器替文章選音。


理解 IVS:讀音怎麼藏在文字裡

Unicode 的 variation sequence 由基底字元與其後的 variation selector 組成。bpmfvs 借用這種字形選擇機制,將不同讀音做成不同字形,再以漢字後方的選擇子切換。機制背景可參考 Unicode 的 Variation Sequences FAQ。

在本文檢查的讀音表中,會字的前幾個讀音依序是:

讀音文字序列JavaScript 表達式
ㄏㄨㄟˋU+6703'會'
ㄎㄨㄞˋU+6703 U+E01E1'會\u{E01E1}'
ㄏㄨㄟˇU+6703 U+E01E2'會\u{E01E2}'
ㄍㄨㄟˋU+6703 U+E01E3'會\u{E01E3}'

這裡有個容易看錯的地方:U+E01E0 在此專案表示注音留白,不是第一讀音。第一讀音直接使用沒有選擇子的漢字;第二讀音才加上 U+E01E1。

讀音編號的意義來自 bpmfvs 的資料表與字型約定。不能把它解讀為 Unicode 規定 U+E01E1 一律代表某種發音。Unicode 官方也區分標準化或已登錄的變體序列與自行約定的序列;本專案使用 IVS 技術,不等於它的全部注音對應已獲 Unicode 標準化。

用 JavaScript 看見不可見的選擇子

光看螢幕上的字,很難確定資料有沒有保留。我會直接列出碼位:

const selected = '會\u{E01E1}';
const codePoints = Array.from(selected, ch =>
  'U+' + ch.codePointAt(0).toString(16).toUpperCase()
);
console.log(codePoints); // ['U+6703', 'U+E01E1']
console.log(selected.length); // 3,UTF-16 code unit 數量
console.log(Array.from(selected).length); // 2,Unicode 碼位數量

這個範例也說明了 JavaScript 的陷阱:選擇子在 BMP 之外,需要兩個 UTF-16 code unit,因此 length 不等於使用者看到的字數。反過來說,Array.from() 雖然能逐碼位迭代,也不會自動把漢字與選擇子合成一個編輯單位。


查看字型如何把編碼畫成注音

前端候選列沒有替每個字另外排一組注音 HTML。它只是產生同一漢字加不同選擇子的文字,再套用網頁載入的注音字型。因此,候選列本身也是這套字型規格的示範。

往 make_font.rb 的 create_zhuyin_glyphs 看,可以看到程式將漢字字形與注音字形組合,第一讀音寫入一般的 cmap,後續讀音則寫入 cmap_uvs。OpenType 對變體序列的對應機制可參考 cmap format 14。

這也解釋了更換字型仍能保持選音的前提:兩套字型必須對相同漢字與選擇子,使用相同的讀音順序。一般中文字型不必遵循這個約定,不同年代的字型也必須確認是否收錄所需字形。

方式選擇資訊放哪裡跨軟體複製時要注意什麼
傳統多字型方案每個字使用的字型名稱更換整篇字型時,原本的區分可能失去
bpmfvs 的 IVS 方案文字內的異體字選擇子軟體須保留文字序列,字型須有相符映射
GSUB 文體集應用程式保存的 OpenType 排版設定純文字複製不包含這類設定

專案字型也提供 GSUB 文體集,但這個網頁工具的主要選音輸出採 IVS,沒有替 Word 或 Illustrator 設定文體集。兩條路徑不要混為一談。


找到詞庫:三份資料各有不同用途

我追程式時,最先需要釐清的是詞庫到底指哪個檔案。實際上,這裡至少有三份不同角色的資料:

檔案保存內容何時使用
phonetic/phonic_table_Z.txt漢字、碼位、來源群組與依序排列的讀音產生字型與瀏覽器詞庫時
ime/poyin_db.txt各字各讀音對應的詞彙規則人工維護,再交給 Ruby 轉換
ime/poyin_db.js可直接載入的 JavaScript 資料物件網頁執行時

完整讀音表的來源策略記錄在 phonetic/README.md,涵蓋專案採用的教育部讀音資料、化學用字、全字庫與新酷音交集,以及人工補充等。這是專案的資料整理策略;本文不將其中對舊版審訂表狀態的歷史敘述,當成目前教育政策的結論。

讀音順序是相容性契約。如果把第二、第三讀音交換,舊文章裡原本的選擇子仍然存在,顯示出的發音卻會改變。因此,專案的 make_ptable.rb 會參考先前釋出的讀音表,維持既有順序,將新增讀音附加在後面。

讀懂星號與斜線的意思

以下是 poyin_db.txt 裡的實際資料:

[仔] ㄗˇ
[仔] ㄗㄞˇ    打*/牛*/公*/豬*
[會] ㄏㄨㄟˋ
[會] ㄎㄨㄞˋ  *計/管*
[會] ㄏㄨㄟˇ  一*
[會] ㄍㄨㄟˋ  *稽

* 代表這筆規則的目標漢字,/ 分隔不同詞彙。以仔為例,牛* 會對應牛仔,再選取 ㄗㄞˇ。星號不是任意字串萬用字元,也不是正規表示式;它是目標字的定位記號。

第一讀音的詞彙可以留空,因為沒匹配到規則時,原本沒有 IVS 的文字就維持預設字形。不過,第一讀音也可以列規則,讓介面把該字標成已猜測,提醒使用者確認。


拆解 JavaScript 的四個主要動作

index.html 直接載入 jQuery、poyin_db.js、ime.js 與字典介面。核心選音不需要後端 API,也沒有載入語言模型。

  1. 拆解文字並建立逐字編輯元素

    setEditorText() 將輸入拆成內部的 text 陣列,嘗試把 UTF-16 surrogate pair、選擇子,以及自訂注音使用的後續字元保留在同一項,再替文字建立帶索引的 span。

    若該字在 data 裡,就加上多音字的 p class;若資料另有 f 標記,再加上 fuzzy。已有選擇子的文字則標成已選擇或特殊自選。這套拆解方式是專案自己的實作,並不是通用的 Unicode grapheme 分段器。

  2. 用前後文比對詞彙

    autoSelect() 逐項檢查 text,找到多音字後,巡覽該字的各讀音及詞彙列表,再呼叫 match()。比對程式先找到星號在詞彙中的位置,據此往前定位,擷取等長的文章片段。

    例如處理牛仔的仔時,規則 牛* 的星號位於第二個位置,程式便檢查前一項與目前這項是否組成牛仔。吻合時,將目標項改寫為漢字加選擇子,並加入 auto class。

    如果原文的這個字已帶有 IVS,autoSelect() 會設定 onlydic,繼續整理字典查詢資訊,但不覆寫原本的讀音。這是保護既有人工選音的行為,不代表程式已驗證該讀音符合上下文。

  3. 產生候選並處理手動選音

    goNext() 負責前後移動與建立候選列。候選數量取自 data[c].s,每個候選用漢字搭配不同選擇子產生,點擊後就更新 text[curr]、把狀態改成 ok,再移到下一個多音字。

  4. 完成後串回文字

    按下完成時,程式將 text 陣列串接起來,寫回 textarea 並呼叫 select()。它沒有在這一步產生 Word 檔,也沒有自動把內容寫入剪貼簿;使用者仍要自行複製。

字典則是另一套輔助功能:textinfo 記錄匹配到的詞彙,zhdicui.js 與 ToneOZ 字典元件再用這些資料查詢內附的字典。查字典的資料與自動選音詞庫有分工,不能把兩者的收錄量當成同一件事。


修改詞彙後,用 Ruby 重建網頁詞庫

瀏覽器載入的是 poyin_db.js,所以只修改 poyin_db.txt,重新整理網頁不會生效。正確流程是在儲存庫中修改文字詞庫,再執行產生器:

cd ime
ruby gen_jsdb.rb
# 先確認產生器實際修改了哪些內容
git diff -- poyin_db.txt poyin_db.js

README 把產生器寫成 poyin_db.rb,但本文版本實際的檔名是 gen_jsdb.rb。而且這個程式使用相對路徑讀檔,必須注意執行目錄。

產生器會從完整讀音表取得多音字與讀音順序,再合併人工詞彙規則;它也讀取 phonic_types.txt,建立自訂注音使用的 ruby 對照物件。最後輸出 var data = ... 與 var ruby = ...。

// poyin_db.js 中「會」的資料結構
"會": {
  "s": 4,
  "v": ["", "*計/管*", "一*", "*稽"]
}

s 是讀音數量,v 是依讀音順序排列的詞彙規則,索引 0 對應第一讀音;f 則是選用的易猜錯標記。前端不需要先解析整份原始讀音表,就能建立候選與執行比對。

我統計目前的 data,共有 3,235 個多音字項目,其中 534 個有詞彙規則。這不是完整讀音表的漢字總數,也不是字典詞條數,更不代表每個字的每種讀音都有自動判斷能力。

我另在獨立暫存目錄中,複製產生器及必要資料執行 Ruby,產出的 poyin_db.js 與儲存庫版本逐位元組相同。這項檢查確認本文版本的產生流程可重現,不需要因此重建字型。

這裡要留意副作用:產生器也會重寫 poyin_db.txt,不只輸出 JavaScript。程式遇到未知漢字或讀音時會印出 Error: 並略過該筆,因此不能只看結束碼判定資料正確;還要閱讀輸出並檢查差異。


實測選音結果與目前的限制

我在本機瀏覽器直接檢查 text 陣列、DOM class 與輸出碼位。以下使用選擇子的文字表示法,避免一般字型把差異隱藏:

輸入情境實測結果代表的行為
牛仔牛仔 + U+E01E1命中 牛*,仔選為第二讀音
開會輸出碼位維持開會沒有匹配規則,會維持預設第一讀音
會 + U+E01E2 + 計原本的 U+E01E2 保留已帶 IVS 的字不被自動選音覆寫,即使與詞彙預期不同
會計自動選音後,手動點第一候選會後方的選擇子移除,class 改成 ok第一讀音使用裸字,手動狀態另由介面保存
一會計一會 + U+E01E2 + 計人工組合的衝突測試,同時命中 *計 與 一*,後者覆寫前者

最後一筆不是拿來評斷自然語句的正確發音,而是刻意讓兩條規則同時吻合,用來觀察程式的優先順序。

規則衝突時,不能假設第一筆命中就停止

讀 autoSelect() 時,很容易以為下面這行表示成功後立即跳出:

if (match(c, i, list[n], j, onlydic)) break here;

但 match() 失敗時會回傳 false,成功路徑卻只更新文字與字典資訊,沒有回傳 true,最後得到的是 undefined。因此,上面這個條件在成功時也不會成立,後續吻合的規則仍會執行。

所以目前不能把它描述為最長詞優先、第一筆命中優先,或有信心分數的語意判讀器。若要改寫這段演算法,必須先決定規則優先順序,再處理回傳值;單純加上 return true 也會改變既有結果。本文只分析行為,沒有修改工具。

有處理 surrogate pair,不代表所有路徑都完整支援

另一個邊界是非 BMP 漢字。程式有 unicharAt() 幫助取得代理對,但建立多音字標記、前後導航與部分比對仍使用 charAt(0)。

我確認 data 裡有 𣎴 這個字,資料標示兩個讀音。然而輸入 會𣎴 時,該字仍被標成一般字的 c class,而不是多音字的 p。這表示補充平面漢字的處理仍有不一致,不能因為輸入沒有破字就認定整套選音功能都正常。

對一、不變調、兒化、連接詞和,以及文言文,專案 README 也明列推測限制。目前主要靠詞庫列舉,沒有完整的下一字聲調推導或句法分析。橘色已猜測只表示程式命中規則,並不是讀音正確的保證。


使用特殊自選與注音留白

我查 README 時也看到另一個落差:文件仍寫工具尚未支援 U+E01E0 留白,但目前的 showDialog() 與選項事件已實作特殊自選。候選列最後一個項目會開啟對話框,包含注音留白、注音填空、自訂注音與漢字填空。

一般注音留白使用漢字加 U+E01E0;自訂注音則在這個序列後加上專案對應的 PUA 字元,由 ruby 物件取得碼位。注音填空使用的也是專案特定的組合。

這些功能適合教材排版,但它們比一般選音更依賴字型約定。自訂注音不是一串標準注音文字的同義表示;如果後續系統需要語音合成、語言分析或交換發音資料,應另外保存明確的注音或讀音欄位。


確認跨軟體複製與更換字型的結果

IVS 把選音存進文字,能降低讀音依賴文件格式的問題,但實務上仍要區分資料保留與顯示支援。依 Unicode 的說明,不支援某個變體序列時,通常會顯示基底字元並隱藏選擇子;這不等於畫面上能看見選音。

  • 資料層:複製、儲存與傳輸是否保留選擇子,有沒有被清理程序移除。
  • 字型層:目標字型是否支援同一套 bpmfvs 映射,並收錄該字與該讀音。
  • 應用程式層:軟體的文字排版引擎是否正確處理序列及字型替代。

我會先用前面的碼位檢查確認資料,再檢查字型,而不是只靠肉眼判斷複製成功。至於資料庫比對、全文搜尋或去重,也要先決定是否把帶選擇子與不帶選擇子的文字視為相同,不能直接假設一般字串相等。

此外,手動選回第一讀音時輸出仍是沒有選擇子的漢字,綠色狀態只留在介面。因此將純文字重新貼回工具,不能要求它記得每一次人工確認。這和第二讀音以上有明確選擇子的情況不同。


總結

這套工具最值得理解的是它如何分配責任:JavaScript 提供選音與校對介面,Ruby 預先整理資料,IVS 把選擇結果放進文字,最後由注音字型顯示。這讓選音不必完全綁在某一份文件的字型格式設定上。

如果要改善自動選音,我會先從 poyin_db.txt 的詞彙與衝突案例著手,再檢查 match() 的回傳及優先順序。若要做可靠的發音資料交換,則應另外保存明確讀音,保留 IVS 作為排版輸出。把推測、資料與顯示分清楚,才知道出現錯音時應該修哪一層。


相關連結

留言評論