將文本放置在 PDF 頁面上的呼叫很直觀。您將字串、字體、大小和位置傳遞給 AddText,然後字形就會出現。它不會做的是告訴您該字串一旦繪製出來會有多寬,而且它不會將長字串跨多行斷開。單一呼叫在一個位置繪製一段文本。如果這段文本的寬度大於您打算讓它適應的欄位,它就會直接越過邊緣,而且繪製呼叫中沒有任何東西會警告您。當您想要的是一個段落而不是一個單一標籤時,缺失的部分就是所選字體和大小之字串的寬度,這需要在將其提交到頁面之前進行測量
這是經典的排版問題。要將段落自動換行至某個欄位中,您必須逐字知道每條候選行將佔用多少水平空間,而且您必須在繪製任何東西之前就知道。自動換行是一個圍繞繪製呼叫的測量迴圈,而一個只負責繪製的綁定僅提供了後半部分。PDFium 元件中的文本測量支援透過 MeasureText 和 MeasureTextWidth 這兩個函式彌補了這一差距,這些函式可以報告字串渲染後的範圍,而不會在任何頁面上留下標記
為何測量功能是類別幫手,而不是 TPdf 上的新方法
測量支援是以 TPdf 的 Delphi 類別幫手(class helper)形式提供,它存於自己的單元中,而不是硬塞進 TPdf 類別中的新方法。類別幫手是一種語言功能,允許您從其宣告外部將方法附加至現有類型。一旦該單元位於作用域內,新方法的呼叫方式就完全如同它們屬於該類別一樣,因此幫手方法的呼叫就像 Pdf.MeasureTextWidth(...),無需建構或傳遞獨立的物件
這樣分層的理由是分離。核心的 TPdf 類型保持原樣,不添加欄位,也不觸碰現有的簽名,因此從不需要排版的專案就永遠不會帶有測量程式碼。而確實需要它的專案只需在 uses 子句中加入一個單元,這些方法就會啟動。能力變成了單一單元粒度級別的選擇性加入(opt-in),這是擴充您未擁有或不想干擾之類型的最乾淨的方式
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 即為像素,這樣您以點(points)為單位設定的欄位寬度就可以在決定斷行位置之前,與測量出的一段文本進行比對
退化輸入時會發生什麼事
這些函式被編寫為會安靜地失敗。如果沒有文件處於開啟狀態,或者無法建立文本物件,結果將是一個零範圍而不是引發例外。寬度和高度在開頭被初始化為零,並且只有在成功讀回邊界框後才會被覆寫。空字串、缺失的文件、函式庫無法將其解析為物件的字體,每一種情況都會傳回零而不是拋出錯誤
這個選擇保持了測量迴圈的簡單,因為一個執行數千個單詞的迴圈,不是在每次迭代中處理例外的地方。代價是呼叫者必須負責檢查。零寬度是一個哨兵(sentinel),而不是關於文本的事實,因此在除以測得的寬度或假設其為正值的程式碼中,必須在信任它之前防範零。將零視為「無法測量」,合約就很清晰;如果忽略它,一個退化(degenerate)輸入就會悄悄地變成一個欄位中字形重疊的排版
建構在測量之上的貪婪式自動換行
手頭有了寬度函式後,自動換行就是一個簡短的貪婪迴圈(greedy loop)。您將段落分割成多個單詞,保留一條目前行,並且對於每個單詞,您測量如果您附加該單詞後這行會變成怎樣。只要試驗行仍然符合欄位寬度,您就繼續添加;當它即將溢出時,您用 AddText 寫入目前行,並用不合適的那個單詞開始新的一行。累積過程完全透過 MeasureTextWidth 完成,且唯一會到達頁面的就是一條您已經確認合適的行
procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
Words: TArray<WideString>;
Line, Trial: WideString;
I: Integer;
Y: Double;
begin
Words := WideString(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(X, Y, Font, FontSize, Line); // 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(X, Y, Font, FontSize, Line); // flush the final line
end;
該迴圈測量的是試驗行,而不是測量每個單詞並求和,因為一行的寬度不是其單詞寬度的總和。單詞之間的空格也有貢獻,而測量一整段文本則直接捕捉到了這一點。這個貪婪規則(盡可能放入欄位允許的單詞數量,並在適合的最後一個單詞處斷開)與填補原始 AddText 和真實段落之間差距的規則相同。繪製呼叫從來都不是困難的部分。必須在它之前的測量才是,而這正是這個幫手所提供的
這適用的地方
測量是產生內容與渲染內容之間的層級,因此它自然會與從頭開始的文件工作流程的其餘部分配對。如果您正在編排頁面並放置文本,那麼基礎工作就在於在 Delphi 中使用 PDFium 元件從頭開始建立 PDF 文件,其中充分涵蓋了 AddText 和頁面設定。當您正在測量的字體與字串一樣重要時(因為度量取決於字體外觀),在 Delphi 中使用 PDFium 元件分析 PDF 字體屬性展示了函式庫如何報告驅動那些邊界框的字體資訊。這兩者都建置在同一個綁定之上,即適用於 Delphi 和 Lazarus 的 PDFium 元件,其中測量幫手與本部落格描述的文件、頁面和文本 API 一起提供