技術文章

Delphi PDFlibPas 斷字與平衡欄位排版

PDFlibPas 透過 DrawTextFlowColumns 把一段保留格式的文字流入一到 64 欄等寬欄位,只要呼叫 SetTextFlowLanguageSetTextFlowHyphenation,就能以有界限的語言感知斷字來斷行。目前支援九種語言,且語言設定可以直接繼承自文件 Catalog 的 /Lang 值,不必為每個文字流分別設定

這兩項功能存在的理由相同:狹窄的欄位正是單純斷行邏輯不再像排版、反而開始像 bug 報告的地方

為什麼齊行文字在狹窄欄位中會散架?

因為齊行是把多餘空間分配到一行文字的字間空隙中,而多餘多少取決於這一行到底能塞進什麼。在較寬的量度下,多餘空間很小,肉眼幾乎察覺不到。若把寬度砍半,一個放不下的長單字就會被推到下一行,把所有多餘空間都留給前面的字詞去吸收。連續三行都發生這種情況,就會產生排版學稱為「河流」的垂直白色通道,讀者感受到的則是一段莫名難以閱讀的文字

斷字是從根源解決問題,而不是治標:它允許在單字內部斷行。德文與荷蘭文的複合字讓這一點毫無妥協餘地:在 60 公釐寬的欄位中放入一個 24 字元的名詞,若沒有斷點就沒有好結果。英文對此的容忍度較高,這也是為什麼以英文為優先的產品,往往在第一次遇到德國客戶時,排版程式碼就整個垮掉

支援哪些語言?語言設定又從何而來?

斷字支援英文、德文、荷蘭文、法文、西班牙文、義大利文、葡萄牙文、俄文與土耳其文。你可以用 SetTextFlowLanguage 為每個文字流明確設定,或是讓它繼承自文件 Catalog 的 /Lang 項目,也就是一份已標記且具無障礙功能的文件本來就會攜帶的值

比起自行覆寫,值得善用這種繼承機制。一份在 Catalog 中宣告語言的文件,等於是把同一個事實同時告訴螢幕報讀軟體、搜尋索引器與斷字功能,而一個事實本來就該只存在一個地方。如果你已經依照 無障礙 PDF 自動標記 中所述的方式產生已標記的輸出,語言項目早已設定完成,文字流只需要跟著沿用即可

var
  Lib: TPDFlib;
  Flow, Drawn: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.AddTrueTypeFont('Georgia', 1);
    Lib.SetTextSize(10.5);

    Flow := Lib.NewTextFlow(ArticleBody);
    try
      Lib.SetTextFlowLanguage(Flow, 'de');
      // 啟用斷字,斷點前至少 3 字元,斷點後至少 3 字元
      Lib.SetTextFlowHyphenation(Flow, 1, 3, 3);
      Lib.SetTextFlowMinLines(Flow, 2);   // 絕不讓單獨一行落單

      repeat
        // 三欄橫跨 480 pt 區域,欄間距 18 pt,並啟用平衡
        Drawn := Lib.DrawTextFlowColumns(Flow, 72, 720, 480, 620, 3, 18, 1);
        if (Drawn = 0) or (Lib.TextFlowFinished(Flow) = 1) then
          Break;
        Lib.NewPage;
      until False;
    finally
      Lib.ReleaseTextFlow(Flow);
    end;

    Lib.SaveToFile('newsletter.pdf');
  finally
    Lib.Free;
  end;
end;

MinPrefix 與 MinSuffix 是排版設定,不是驗證規則

啟用旗標之後的兩個整數,設定的是斷點前後必須保留的最少字元數。3 與 3 是大多數版型規範都能接受的保守預設值。2 與 2 會產生更多斷點機會,結果卻明顯更難看,因為一行結尾掛著一個兩字母的殘片,讀起來像是打錯字

當字級較大、每個殘片在視覺上都更顯眼時,應提高最小值;只有在欄位確實狹窄、而你已經判斷緊湊的量度比乾淨的斷行更重要時,才調低最小值。這是版型規範的決策,而不是技術性的決策,這正是它被設計成參數、而不是常數的原因

這裡的「平衡」到底是什麼意思?

Balance 參數只在段落結尾處改變行為。啟用平衡後,若剩餘內容全部塞得進區域內,各欄會被縮短到完全相等的行數,這正是為了避免最後一頁出現兩欄滿版、第三欄卻只孤零零一行的情況。若剩餘內容裝不下,每一欄都會保留完整高度,讓這一頁盡可能多容納文字,其餘內容則延續到下一頁

這種不對稱是連續型文件正確的預設行為。若在文章持續流動的中段就進行平衡,只會在每一頁上為了沒人會注意到的視覺效果浪費垂直空間,因為各欄本來就是滿版的。平衡只在結尾處才真正被肉眼注意到,而它也正好只在結尾處生效

斷行以完整單字為量測單位

斷行演算法量測的是完整單字,而不是逐字累加字元寬度,並為完全放不進一行的過大字符(例如網址或帳號)保留了有界限的搜尋範圍。這讓常見情況維持快速,也讓極端情況維持有界,而不是反過來

選擇性軟連字號與自動連字號只有在被選中的斷點確實成為實際斷點時才會渲染出來。這聽起來理所當然,卻是一個經典缺陷:不成熟的實作會在量測階段就寫入連字號字元,一旦斷點移動,連字號就會遺留在一行的中間。沒有什麼比一個單字中間憑空冒出的連字號更像是文字引擎壞掉的證據了

var
  Lib: TPDFlib;
  Flow, Needed: Integer;
begin
  // 在繪製任何內容之前先決定版面
  Flow := Lib.NewTextFlow(ArticleBody);
  try
    Lib.SetTextFlowLanguage(Flow, 'fr');
    Lib.SetTextFlowHyphenation(Flow, 1, 3, 3);

    // 剩餘段落在單欄寬度下所需的行數
    Needed := Lib.MeasureTextFlow(Flow, 148);
    if Needed > 3 * LinesPerColumn then
      UseTwoPageSpread
    else
      UseSinglePage;

    Lib.DrawTextFlowColumns(Flow, 72, 720, 480, 620, 3, 18, 1);

    if Lib.TextFlowFinished(Flow) <> 1 then
      CarryOver(Lib.GetTextFlowRemaining(Flow));
  finally
    Lib.ReleaseTextFlow(Flow);
  end;
end;

各文字方塊之間保持相同的字型設定

有一條規則適用於所有以文字流為基礎的版面,值得直白地說出來:DrawTextFlowDrawTextFlowColumnsMeasureTextFlow 全都以呼叫當下所選定的字型來斷行。若在同一個文字流的兩個文字方塊之間更換字型或字級,或是在新的一頁開始時忘了重新選取字型,第二個文字方塊的斷行結果就會與第一個方塊的量測結果不一致

這個症狀特別惱人,正因為它看起來像是間歇性問題:第一頁裝得下的文字,到了第二頁卻溢出,或是量測出的行數與實際繪製結果對不上。只要在迴圈前選定一次字型,並在每次 NewPage 之後重新選取,文字流就會表現正常。當同一段落中出現混合文字系統時,CJK 與 Emoji 文字的自動字型後援 中描述的解析機制同時適用於量測與繪製,所以即使經過後援字型渲染,寬度也依然一致

對於文字流只是頁首、頁尾與資料驅動區塊之一的報表版面而言,資料集報表引擎 中的組版方式能與欄位式文字流乾淨地搭配使用:先量測,放置固定的版面元素,再把剩下的區域交給文字流

PDFlibPas 是一套適用於 Delphi、C++Builder 與 Lazarus 的 PDF 函式庫,完整的 TextFlow 生命週期,包括建立、繪製、量測、檢視、回捲與釋放,同樣透過 DLL 與 ActiveX 介面對外開放。完整文件請參見 PDFlibPas Delphi PDF 函式庫頁面