技術文章

在 Delphi 中量測 PDF 文字以進行版面配置和自動換行

把文字放到 PDF 頁面上的呼叫很直覺。您把字串、字型、大小和位置傳給 AddText,字形就會出現。它不會告訴您該字串一旦繪製出來會有多寬,也不會把長字串拆成多行。單次呼叫只會在一個位置繪製一段文字。如果這段文字的寬度大於您打算讓它容納的欄位,它就會直接越過邊緣,而且繪製呼叫本身不會提醒您。當您想要的是段落而不是單一標籤時,缺少的就是所選字型和大小下的字串寬度,而且必須在把它送到頁面之前先量測

這就是經典的版面配置問題。要把段落自動換行進欄位,您必須逐字知道每條候選行會佔用多少水平空間,而且要在繪製任何東西之前就知道。自動換行是一個包在繪製呼叫外面的量測迴圈,而只負責繪製的 Component 只提供後半段。PDFium Component 的文字量測支援透過 MeasureTextMeasureTextWidth 這兩個函式補上這一段,函式會回報字串呈現後的範圍,卻不會在任何頁面上留下痕跡

為何量測功能是類別幫手,而不是 TPdf 上的新方法

量測支援是以 TPdf 的 Delphi 類別幫手(class helper)形式提供,它放在自己的單元中,而不是硬塞進 TPdf 類別的新方法裡。類別幫手是一種語言功能,允許您從宣告外部將方法附加到既有型別。一旦該單元進入範圍,新方法的呼叫方式就完全和它們屬於該類別一樣,因此幫手方法的呼叫就像 Pdf.MeasureTextWidth(...),不需要另外建構或傳遞物件

這樣分層的理由是分離。核心的 TPdf 型別維持原樣,不新增欄位,也不碰既有簽章,因此從不需要版面配置的專案就不會帶有量測程式碼。而確實需要它的專案只要在 uses 子句中加入一個單元,這些方法就會生效。能力變成單一單元粒度的選配加入,這是擴充您不擁有或不想干擾的型別的最乾淨方式

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

測量而不觸碰頁面

量測必須沒有副作用。它必須在不留下任何東西的情況下回報寬度,因為在決定版面配置時您會呼叫它很多次,而且頁面看起來必須和完全沒有量測過一模一樣。讓這件事成真的方法,是建立一個文字物件、詢問其大小,然後在它被附加到頁面之前把它丟棄

順序是四個 PDFium 呼叫。給定字型名稱和大小後,FPDFPageObj_NewTextObj 會為文件建立一個文字物件。FPDFText_SetText 設定該物件所攜帶的字串。FPDFPageObj_GetBounds 讀回該物件的邊界框。FPDFPageObj_Destroy 釋放該物件。至關重要的是,這個順序中沒有任何步驟會呼叫插入頁面的 API。該物件是被隔離地建立、查詢和銷毀,因此函式回傳時,文件保持不變。它是一個用過即丟的探針,唯一的輸出就是它邊界框的四個數字

這是執行這件事的穩健方法,因為 PDFium 沒有提供方便的逐字形前進寬度(advance width)讓您自己加總。字型度量取決於字型程式、編碼,以及 PDFium 如何載入該字型,而且沒有公開的呼叫能直接給您字串中每個字元的前進量。另一方面,真實文字物件的邊界框是由用來繪製的同一套機制計算出來的,因此它反映的是實際呈現範圍,而不是近似值。建立一個可丟棄的物件並讀取其邊界,是這個函式庫所能提供最可靠的量測

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

結果的座標和單位

邊界框會以左、下、右、上四個邊緣回傳,而兩個維度可透過相減得到。寬度是右減左,高度是上減下。兩者都以 PDF 使用者單位表示,其中一單位是七十二分之一英吋,這和您在頁面上定位文字的座標空間相同。這個階段沒有隱藏的裝置單位,也不涉及像素。36 的寬度代表半英吋的頁面,不管最終的呈現解析度為何都一樣

垂直軸的運作方式遵循 PDF 的定義,Y 值向上增加,這就是為什麼高度是上減下而不是反過來。當您把游標沿著欄位往下移時,這個細節就很重要。您量測一行的高度,然後從目前基線減去它來找下一行,因為往下移動頁面代表朝向較小的 Y 值移動。如果目標是螢幕而不是紙張,您可以用顯示解析度把使用者單位轉成裝置像素:以使用者單位表示的值乘上 DPI 再除以 72 就是像素,這樣您以點數設定的欄位寬度,就能在決定斷行位置前和量測出的文字對照

退化輸入時會發生什麼事

這些函式被寫成會安靜失敗。如果沒有檔案開啟,或者無法建立文字物件,結果會是零範圍,而不是拋出例外。寬度和高度在一開始就初始化為零,並且只有在成功讀回邊界框後才會被覆寫。空字串、缺少的檔案、函式庫無法解析成物件的字型,這些情況都會回傳零而不是丟出錯誤

這個選擇讓量測迴圈保持簡單,因為跑數千個單字的迴圈,不是每一輪都處理例外的地方。代價是呼叫端必須負責檢查。零寬度是一個哨兵值,而不是關於文字的事實,所以在除以量測寬度或假設它為正值的程式碼裡,必須先防範零。把零視為「無法量測」,合約就很清楚;如果忽略它,退化輸入就會悄悄變成欄位裡字形重疊的排版

建構在測量之上的貪婪式自動換行

手上有了寬度函式之後,自動換行就是一個簡短的貪婪迴圈。您把段落分割成單字,保留目前行,然後對每個單字量測如果附加上去之後這一行會變成什麼樣。只要試驗行還能符合欄位寬度,您就繼續加入;當它即將溢出時,您用 AddText 寫入目前行,並用那個放不下的單字開始新的一行。整個累積過程完全透過 MeasureTextWidth 完成,而真正到達頁面的只有您已經確認放得下的那一行

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // flush the final line
end;

這個迴圈量測的是試驗行,而不是逐字相加,因為一行的寬度不是各個單字寬度的總和。單字之間的空白也會計入,而量測整段文字可以直接捕捉到這一點。這個貪婪規則,也就是盡可能放入欄位允許的單字,並在最後一個放得下的地方斷行,正是補上原始 AddText 和真正段落之間差距的那條規則。繪製呼叫從來都不是難點。必須先做的量測才是,而這正是這個幫手提供的

這適用在哪裡

量測是產生內容與呈現內容之間的層,因此它自然會和從頭開始的檔案工作流程其餘部分搭配。如果您正在編排頁面並放置文字,那麼基礎工作就在於在 Delphi 中使用 PDFium Component 從頭開始建立 PDF 檔案,其中完整說明了 AddText 和頁面設定。當您正在量測的字型和字串一樣重要時(因為度量取決於字型外觀),在 Delphi 中使用 PDFium Component 分析 PDF 字型屬性展示了函式庫如何回報驅動那些邊界框的字型資訊。這兩者都建立在同一個綁定之上,也就是適用於 Delphi 和 Lazarus 的 PDFium Component,而量測幫手會隨著本部落格描述的文件、頁面和文字 API 一起提供