Uniscribe 做的工作比大多數呼叫端意識到的多。ScriptItemize 一輪完成雙向分析與文字系統分段,ScriptLayout 產生結果文字段的視覺順序。人們伸手去拿的可攜替代品 HarfBuzz 兩樣都不做:它對一個方向與文字系統已由別人決定好的單一 run 做塑形。所以把 Windows PDF 文字管線搬到 Linux 或 macOS 的難點,不在繫結一個塑形引擎,而在補上 Uniscribe 一直在悄悄提供的那套雙向演算法——在 PDFium 元件裡,這正是 FPdfBidi 的職責
該單元直接實作 UAX #9:P2 與 P3 處理段落方向,X1 到 X10 處理顯式內嵌與隔離,W1 到 W7 處理弱型別,N0 到 N2 處理中性類別與括號,I1 與 I2 處理隱含層級,L1 與 L2 處理最終重排。兩個函式承載它:PdfResolveBidiLevels 為每個 UTF-16 碼元回傳一個內嵌層級,PdfBidiVisualOrder 把那些層級變成把碼元由左至右放置的排列
演算法給您什麼,不給什麼
它給您數字。偶數層由左至右,奇數層由右至左,每個字元的層級編碼了它所處方向 run 的巢狀。L2 從那些數字推導出一個排列。演算法刻意不做的是:決定用哪套字型、組連字、或重排叢集內的字形;那些是塑形的職責,屬於這一階段之後
uses
FPdfBidi;
var
Levels: TPdfBidiLevels;
Order: TPdfBidiOrder;
ParagraphLevel: Byte;
Text, Visual: WideString;
I: Integer;
begin
Text := SourceLine;
// pbdAuto 套用 P2-P3:由第一個強字元決定
if PdfResolveBidiLevels(Text, pbdAuto, Levels, ParagraphLevel) then
begin
Order := PdfBidiVisualOrder(Text, Levels);
SetLength(Visual, Length(Order));
for I := 0 to High(Order) do
Visual[I + 1] := Text[Order[I] + 1];
// Visual 現在從左讀到右;Levels[] 仍指出哪些文字段是
// RTL,塑形器因此能拿到正確方向
end;
end;
字元類別表是產生的,不是手寫的
每個字碼點都有一個 Bidi_Class 屬性,演算法不斷查它,所以這張表是一切立足的地基。它從 Unicode 字元資料庫產生,而不是手工維護:UnicodeData.txt 的第五欄給出已指派的類別,DerivedBidiClass.txt 的 @missing 宣告給資料庫未指派字碼點的預設值——未配置區塊因此正確預設為 R、AL、ET 或 BN,而不是 L
壓縮技巧是只發出類別不是 L 的範圍。落在所有範圍之外的東西就是 L,這既是 Unicode 預設,也是絕大多數字碼點的類別。這把一張原本會有數千項的表縮到 745 個範圍、約 6.7 KB。營運上的後果值得說明:升級到新的 Unicode 版本時,重跑產生器。手改 include 檔也能用,但它會在下一次升級時悄悄偏離資料庫
L2 必須重排字碼點,而不是 UTF-16 碼元
這是會產出真正損毀輸出的錯誤,第一版實作就犯了。L2 說從最高層往下到最低奇數層,反轉每一層上的連續 run。寫在 UTF-16 字串上,「反轉一個 run」自然意味著反轉其中的碼元。對基本多文種平面的字元這沒問題。對增補平面的 RTL 字元——例如 U+10800 附近賽普勒斯文或古南阿拉伯文區塊裡的那些——就不行:字元是一個代理對,反轉 run 會把低位代理排到高位代理之前,字串裡現在是兩個未配對代理而不是一個字元。下游沒有任何東西能救回它
修法是在字碼點單位上做 L2。實作把碼元合併成字碼點單位,對那些單位做反轉,最後再把結果展開回碼元索引。這正是 PdfBidiVisualOrder 接收文字而不只接收層級陣列的原因:光看層級它無從得知代理邊界在哪裡。同樣的代理對紀律貫穿整個文字 API,如emoji、CJK 與代理對文章所述
層級下行走訪必須包含沒有出現的層級
第二個錯誤更微妙,它不當機,只是文字沒有被重排。L2 說從在場的最高層開始,往下走到最低奇數層。一個自然的最佳化是收集實際出現的層級集合,對那個集合迭代。這是錯的
想一個在由右至左內嵌裡的拉丁文字行。段落層級是 0,內嵌把拉丁字元推到層級 2,沒有任何字元位於層級 1。對出現層級迭代的話只找到 0 與 2,完全沒有奇數層,所以迴圈不做任何反轉。那個答案是對的,但理由是這個最佳化不知道的:層級 2 的反轉接層級 1 的反轉會恰好抵消,所以兩個都不做才是正確結果。把輸入稍微改一下,讓層級 1 與層級 3 的字元都存在、層級 2 卻沒有,以集合為基礎的迴圈就會跳過演算法要求的層級 2 反轉
// 正確:從最高層往下走到最低奇數層,逐層走訪,
// 包括沒有任何字元實際擁有的層級
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
ReverseRunsAtOrAbove(Level); // 沒有符合的文字段時為無動作
Dec(Level);
end;
寫成普通的遞減迴圈,正確行為自然浮現,無動作的迭代成本量測不出來。這是一個明顯的最佳化不是略微出錯、而是以輸入相關方式出錯的案例,小型測試語料永遠揭露不了它
括號:務實表格上的 BD16
規則 N0 與 BD16 括號對演算法的存在,是為了讓混合方向文字裡的括號解析為它所包夾內容的方向,而不是碰巧相鄰的方向。這需要一張括號對表。實作攜帶的是通用括號對,而不是 Unicode 括號檔的全部內容:ASCII、CJK、全形、數學與裝飾性括號
未列入表的括號不是錯誤。它經 N1 與 N2 作為普通中性類別解析,這正是 Unicode 6.3 引入 N0 之前每個實作的行為。所以邊界是「對罕見括號不夠精緻」,而不是「不正確」。有一個細節確實需要明確處理:U+2329 與 U+232A 的角括號,和 U+3008 與 U+3009 的角括號之間的正規等價,必須在配對時摺疊,否則一種寫法的開括號會配不上另一種寫法的閉括號
三十條互相作用的規則怎麼測
不是靠大語料,至少一開始不是。有生產力的做法是十六個人工驗證過的案例,每個案例挑來演練一條特定規則,並對照 UAX #9 說應產出的層級檢查:P2 與 P3 下的段落方向偵測、弱型別規則 W2、W3 與 W7、隱含層級規則 I1 與 I2、經 X2 與 X7 的顯式內嵌、經 X5a 與 X6a 的隔離、行尾空白與分隔符的 L1 重設、一個 N0 括號案例,以及一個增補平面字元案例,把代理處理釘死
十六個帶已知正確期望層級的案例,能抓到的錯誤多過一千六百個輸出看似合理的案例,因為雙向實作的失敗模式是「讀起來幾乎正確」的文字。這些通過之後,語料對找表格缺口與效能問題有用,那是不同類別的缺陷
在 PDFium 元件內部,層級餵給兩個消費者。寫入側,它們告訴塑形後端每個文字段的方向,那是 HarfBuzz 需要的輸入。讀取側,它們決定選取幾何與閱讀順序,因為在 RTL 文字裡點擊必須對應到邏輯位置而不是視覺位置;那個對應在視覺行選取文章中涵蓋,閱讀順序模型在結構化文字區塊與閱讀順序中。元件的平台支援細節在 PDFium Delphi component 產品頁