完整對齊 (Full justification) 是一種讓文字直行在左右邊緣都對齊的排版,這正是您從印刷書籍或正式報告中所期望的版面外觀。這很容易描述,但出乎意料地容易做錯,因為「多餘的空間應該放哪裡」這個問題,英文和日文的答案並不相同,而且因為用單純天真的方式測量每一行,會把快速的頁面變成緩慢的頁面。HotPDF 透過單一的框排版 (box-layout) 呼叫,為您提供感知文字腳本的對齊功能,而在該呼叫的底層,存在著一個本身就值得了解的教科書等級效能修正。
本文將探討這兩方面。首先是排版規則,決定了有字詞間隙的腳本與沒有間隙的腳本之間如何分配鬆弛空間 (slack)。其次是測量方式的改變,它將每頁的對齊成本降低了約 80 倍,而且在輸出上沒有可見的差異。如果您大量產生文件,並希望它們讀起來像真正的排版,而不是被拉伸以適應空間的等寬輸出,那麼這兩點都很重要。
完整對齊實際需要什麼
以自然寬度繪製的文字行幾乎永遠不會剛好碰到其直行的右邊緣。在最後一個字形 (glyph) 結束的位置和直行邊界之間,總是會有一個剩餘空間,也就是鬆弛空間 (slack)。左對齊會將這個鬆弛空間留在右側。右對齊將其移至左側。置中對齊則將其平分。完整對齊會加寬文字行本身直到兩邊緣都碰到外框以消除它,而唯一誠實的做法是從內部將字形推開。
區分好的對齊與不好的對齊的規則在於您把鬆弛空間放在哪裡。在字詞之間寫有空格的腳本,例如英文和其餘拉丁語系,在每個字詞間的空格都有自然的接縫。加寬這些空格對眼睛來說是不可見的,因為讀者已經接受了字距會有所不同。沒有字詞間隙的腳本,例如中文漢字、日文假名或韓文諺文,就沒有這樣的接縫。在那裡,鬆弛空間必須均勻地分佈在相鄰的字形之間,這就是日本排版人員稱為均等配置 (kintou-waritsuke) 的原則,即均等間距。將拉丁風格的字距拉伸放在 CJK (中日韓) 文字行上,或者將所有鬆弛空間塞入 CJK 文字行碰巧包含空格的唯一位置,會產生標誌著業餘輸出的河流和縫隙 (rivers and gaps)。
HotPDF 如何決定空間的去向
HotPDF 是針對每個間隙來做這個決定,而不是針對每一行。當它對齊一行時,它會走訪每一對相鄰的字形,並詢問它們之間是否存在可拉伸的邊界。當任一側是空格或定位點 (tab) 時,這是拉丁文字的情況;或者當兩側都是 CJK 可換行字元時,這是均等間距的情況,該邊界就是可拉伸的。它會計算這些邊界的數量,將行的鬆弛空間均分給它們,並將該份額加到每個符合條件的間隙中。
結果自然而然地產生。英文文字行只有在其字距處才有可拉伸的邊界,因此所有鬆弛空間都會落在該處,字詞會分開,而每個字詞內的字母會保持其自然的間距。漢字或假名行在幾乎每一對字形之間都有可拉伸的邊界,因此鬆弛空間會均勻分佈在整行,完全符合這些腳本所要求的均等字元間距。如果文字行是一個沒有內部空格的單一長拉丁字詞,則完全沒有可拉伸的邊界,因此 HotPDF 會將其保留為自然寬度,而不是逐字拆散這個詞。相同的邏輯無需特殊處理即可在一行中處理混合的拉丁文和 CJK 文字段 (runs),因為該決定是針對每個邊界局部進行的。
有一個邊界在所有地方都被故意排除。一行中最後一個字形之後的位置永遠不會被視為間隙,因為在那裡拉伸只會重新引入右側的剩餘空間,這與對齊背道而馳。
為什麼最後一行會保持原樣
段落的最後一行很特別,而弄錯它是最常見的對齊錯誤。段落的最後一行通常很短,往往只有幾個詞,將其拉伸至全直行寬度會把這些詞拖曳過頁面,形成稀疏、破碎的一行。正確的排版會將最後一行保留在其自然寬度,並向左對齊。
HotPDF 透過位置來偵測最後一行。當它將文字換行時,它知道剛剛分割出來的行何時到達了提供的字串的結尾。最後一行會以純左對齊的方式輸出,並保持其自然寬度。在它之前的每一行都會對齊至左右兩邊緣。您寫入文字中的硬分行符號 (hard line breaks) 也會照樣被採用,因此刻意縮短的行也永遠不會被拉伸。讀者會看到一個乾淨的矩形文字區塊,其最後一行自然結束,這正是眼睛所期望的。
讓對齊變慢的測量成本
要對齊一行,您必須知道它的確切寬度,並且必須知道每個字形的步進 (advance),這樣您才能精確地放置額外的空間。最初的實作以直觀的方式取得這些數值。它使用完整的 Unicode 寬度查詢來測量整行,然後測量一個個的前綴,透過求差來恢復每個字形的步進。對於有 N 個字形的行來說,這就是 N+1 次呼叫測量引擎,而每次呼叫都是一次完整的 GDI 來回往返 (round-trip),要求作業系統對文字進行整形 (shape) 和測量並傳回答案。
就單行而言,這聽起來代價很低。但在整個頁面上卻並非如此。以一頁密集的 A4 內文為例,大約四十五行,每行約八十個字元。在每行 N+1 次來回往返的情況下,每一行大約有 81 次來回往返,而整頁大約是 3,645 次,其中幾乎所有的時間都花在重新測量引擎剛才已經看過的文字上。在產生數千頁的批次作業 (batch job) 中,這種額外負擔 (overhead) 主導了排版時間,而且每次來回往返都跨越了您的程序 (process) 與圖形子系統之間的邊界。
一次呼叫取代 N 加一
這項修正是那種看似微小但回報巨大的改變。GDI 已經可以在單次查詢中回報字串的總寬度和每個字形的位置。HotPDF 透過 GetWideCharAdvances 公開了該功能,它會將每個字形的自然步進(包含字距微調字距 (kerning))填入陣列,並在一次呼叫而不是 N+1 次呼叫中傳回總寬度。對齊常式 (在內部為 _HPDFEmitJustifiedWideLine) 會要求所有步進一次,計算鬆弛空間,將其分佈在可拉伸的邊界上,然後輸出文字行。
對於同樣那頁 A4 頁面,每行的測量次數從大約 81 次來回往返下降到 1 次,因此整頁的次數從大約 3,645 次來回往返下降到大約 45 次,接近 80 倍的減少。輸出的結果是位元組等級 (byte-for-byte) 的完全相同,因為除了要求的次數之外,測量的任何細節都沒有改變。相同的 GDI 引擎、相同的字型度量 (metrics)、相同的字距微調提供了相同的數值。只有來回往返的次數下降了。當測量已經是正確的時候,正確的優化方式是停止重複地要求它,而不是去近似它。
文字行如何到達頁面
一旦分配好鬆弛空間,HotPDF 就會使用 ExtTextOut 和每個字形的步進陣列 Dx 陣列來輸出文字行。每個項目都是從一個字形的原點到下一個字形原點的距離,這就是該字形的自然步進,加上當其後有可拉伸邊界時它所分得的鬆弛空間。這直接對應到 PDF 繪圖模型 (imaging model)。定位的文字是使用 TJ 運算子寫入的,這是一個將文字行區段 (glyph runs) 與明確的水平調整交錯的陣列,而 Dx 的數值恰好成為這些調整值。這就是為什麼額外的空間會以精確的次點 (sub-point) 位置落在字形之間,而不是用填充字元來造假,這也是為什麼如果下游工具將其讀回,對齊的 HotPDF 文字行能正確地被測量。
對於對齊的段落,您不需要自己呼叫 ExtTextOut。進入點是 WideTextOutBox,它將 Unicode 字串換行裝入外框中,並套用您要求的對齊方式。它將文字分割成適合框寬度的行,沿著框的高度放置每一行,並傳回在垂直空間用盡之前它成功排入的字元數量。對齊方式由對齊列舉 (enum) 決定。
type
THPDFJustificationType = (jtLeft, jtCenter, jtRight, jtJustify);
前三個是不言自明的左對齊、置中對齊和右對齊。第四個 jtJustify 是本文所描述的左右兩端完整對齊,它是 WideTextOutBox 讀取以開啟感知文字腳本間距的數值。
在實際應用中對齊段落
一個完整的範例會建立一個文件,設定字型,並將一個段落以完整對齊的方式排入外框中。相同的程式碼可以在不更改旗標 (flag) 的情況下對齊拉丁文和 CJK 文字,因為文字腳本感知 (script-awareness) 存在於 API 的底層。
uses
HPDFDoc;
procedure JustifyParagraph;
var
Pdf: THotPDF;
Body: WideString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'Justified.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', 11);
Body :=
'Full justification spreads the slack on each filled line so both ' +
'edges meet the column, while the last line keeps its natural width. ' +
'For scripts with word gaps the space lands between words; for ' +
'scripts without them it spreads evenly between glyphs.';
// X, Y, LineSpacing, BoxWidth, BoxHeight, Text, Align
Pdf.CurrentPage.WideTextOutBox(72, 72, 4, 380, 240, Body, jtJustify);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
要將相同的區塊繪製為左對齊、置中對齊或右對齊,只需將最後一個引數變更為 jtLeft、jtCenter 或 jtRight。換行、文字行的放置和傳回值都保持不變。驅動這四個路徑的測量寬度來自 GetWideTextWidth,這是一個具有 Unicode 意識的寬度查詢,可以正確地測量 WideString,而舊的位元組 (byte-wise) 測量則會對 Latin-1 之後的任何內容給出錯誤的大小,這正是讓外框一開始就能在正確的位置對 CJK 和代理對 (surrogate-pair) 文字進行換行的原因。
對齊是更大的文字整形 (text-shaping) 堆疊中的一層。當文字行包含會重新排序或連接其字形的腳本時,此處的間距決定會建立在我們關於複雜腳本文字整形的文章中所描述的基礎工作之上;而當字型帶有您想要選取的排版變體時,請參閱如何驅動 OpenType GSUB 樣式替代 (stylistic alternates)。所有這些都隨附於適用於 Delphi 和 C++Builder 的 HotPDF 元件中,並與本部落格探討的更廣泛的文字、排版和文件 API 放在一起。