Technical Article

DelphiでのレイアウトとワードラップのためのPDFテキストの測定

PDFページにテキストを配置する呼び出しは単純です。AddTextに文字列、フォント、サイズ、および位置を与えると、グリフが表示されます。しかし、それがしてくれないことは、その文字列が描画された後にどのくらいの幅になるかを教えてくれること、そして長い文字列を複数行にわたって折り返すことです。1回の呼び出しは、1つの位置に1つのテキストの連続(run)を描画します。その連続が、フィットさせようとした列よりも広い場合、単に端を通り越して実行され、描画の呼び出しでは何も警告されません。単一のラベルではなく段落が必要になった瞬間、欠けている部分は、ページにコミットする前に測定された、選択したフォントとサイズでの文字列の幅なのです

これは古典的なレイアウトの問題です。段落を列に折り返すには、単語ごとに、各候補行がどの程度の水平方向のスペースを取るかを知る必要があり、何かを描画する前にそれを知らなければなりません。ワードラップとは、描画呼び出しの周りにラップされた測定ループであり、描画のみを行うバインディングはその後半しか提供しません。PDFiumコンポーネントのテキスト測定サポートは、ページにいかなるマークも付けることなく文字列のレンダリング範囲を報告するMeasureTextMeasureTextWidthという2つの関数によって、そのギャップを埋めます

測定がTPdfの新しいメソッドではなく、クラスヘルパーである理由

測定サポートは、TPdfクラスに新しいメソッドとしてボルトで固定されるのではなく、独自のユニットに存在するTPdf用のDelphiクラスヘルパーとして到着します。クラスヘルパーは、既存の型に対してその宣言の外部からメソッドをアタッチできるようにする言語機能です。ユニットがスコープに入ると、新しいメソッドはあたかもそのクラスに属しているかのように正確に呼び出されるため、ヘルパーメソッドはPdf.MeasureTextWidth(...)として読み取られ、別個のオブジェクトを構築したり渡したりする必要はありません

このようにレイヤー化する理由は分離(セパレーション)です。コアのTPdf型は、フィールドが追加されたり既存のシグネチャが変更されたりすることなくそのまま残るため、レイアウトを決して必要としないプロジェクトは測定コードを保持しません。それを必要とするプロジェクトは、uses句に1つのユニットを追加すると、メソッドが点灯します。機能は単一ユニットの粒度でオプトインになります。これは、自分が所有していない、または乱したくない型を拡張する最もクリーンな方法です

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // ヘルパーユニット。MeasureTextをTPdfのスコープにもたらす

// ユニットがスコープにあると、メソッドはTPdfのメンバーとして読み取られる:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // WとHは、PDFユーザー単位でのレンダリングされた幅と高さになる
end;

ページに触れずに測定する

測定には副作用があってはなりません。レイアウトを決定する間に何度も呼び出すため、何かを残すことなく幅を報告しなければならず、ページは、まったく測定しなかった場合と完全に同じように見える必要があります。これを可能にする技術は、テキストオブジェクトを構築し、そのサイズを尋ね、ページにアタッチされる前にそれを破棄することです

シーケンスは4つのPDFium呼び出しです。FPDFPageObj_NewTextObjは、フォント名とサイズが与えられたドキュメントに対してテキストオブジェクトを作成します。FPDFText_SetTextは、そのオブジェクトが保持する文字列を設定します。FPDFPageObj_GetBoundsは、オブジェクトのバウンディングボックスを読み戻します。FPDFPageObj_Destroyはオブジェクトを解放します。決定的なのは、そのシーケンスのどこにもページ挿入APIを呼び出すものがないということです。オブジェクトは分離された状態で作成、照会、および破棄されるため、関数が戻ったとき、ドキュメントは変更されていません。これは使い捨てのプローブであり、その唯一の出力はそのバウンディングボックスの4つの数値です

PDFiumは、自分で合計できるような便利なグリフごとのアドバンス幅(送り幅)を公開していないため、これが堅牢な方法です。グリフメトリクスは、フォントプログラム、エンコーディング、およびPDFiumが書体をロードする方法に依存しており、文字列内の各文字のアドバンスを渡してくれるようなパブリックな呼び出しはありません。一方、実際のテキストオブジェクトのバウンディングボックスは、描画のためにグリフをレイアウトするのと同じメカニズムによって計算されるため、近似ではなく実際のレンダリング範囲を反映します。1つの使い捨てオブジェクトを構築してその境界を読み取ることは、ライブラリが提供できる最も信頼性の高い測定です

// MeasureTextの形状。検証済みのPDFium呼び出しに対して表現されている。
// テキストオブジェクトが構築、測定、および破棄される。ページは関与しない。
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);   // プローブは破棄され、ページには触れられない
  end;
end;

結果の座標と単位

バウンディングボックスは、左、下、右、上の4つの端として戻ってきて、2つの寸法は引き算によって導き出されます。幅は右マイナス左であり、高さは上マイナス下です。どちらもPDFユーザー単位で表され、1単位は1/72インチであり、ページ上にテキストを配置するのと同じ座標空間です。この段階では、隠されたデバイス単位やピクセルは関与しません。36という幅は、最終的なレンダリング解像度がどうであれ、ページの半インチを意味します

垂直軸はPDFが定義する方法で実行され、Yは上に向かって増加します。これが、高さが逆ではなく、上マイナス下である理由です。カーソルを列に沿って下に進めるとき、その詳細は重要です。行の高さを測定し、それを現在のベースラインから引いて次のベースラインを見つけます。なぜなら、ページを下に移動することは、より小さなYに向かって移動することを意味するからです。出力先が紙ではなく画面である場合、ディスプレイ解像度を使用してユーザー単位をデバイスピクセルに変換します。ユーザー単位の値にDPIを掛けて72で割るとピクセルになるため、ブレークの場所を決定する前に、ポイントで設定した列幅を測定された連続と照合できます

退化した入力で起こること

これらの関数は、静かに失敗するように書かれています。ドキュメントが開かれていない場合、またはテキストオブジェクトを作成できない場合、例外が発生するのではなく、結果はゼロ範囲になります。幅と高さは先頭でゼロに初期化され、バウンディングボックスが正常に読み戻された場合にのみ上書きされます。空の文字列、欠落しているドキュメント、ライブラリがオブジェクトに解決できないフォント、これらはそれぞれスローするのではなくゼロを返します

数千の単語にわたって実行されるループは、各反復で例外処理を行う場所ではないため、この選択は測定ループをシンプルに保ちます。コストは、呼び出し元がチェックを負担することです。ゼロの幅はテキストに関する事実ではなく番兵(センチネル)であるため、測定された幅で除算するコード、または正の値を想定するコードは、それを信頼する前にゼロからガードしなければなりません。ゼロを「測定できなかった」として扱えば契約は明確ですが、無視すると、退化した入力は静かに重複するグリフの列を持つレイアウトになってしまいます

測定の上に構築された貪欲なワードラップ

幅関数を手に入れれば、ワードラップは短い貪欲な(greedy)ループになります。段落を単語に分割し、現在の行を保持し、各単語について、その単語を追加した場合の行がどうなるかを測定します。試行行がまだ列の幅に収まる間は追加し続けますが、オーバーフローしそうになったら、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];
    // 何かを描画する前に候補行を測定する。
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // 収まった行をフラッシュする
      Y    := Y - LineHeight;                    // Yは下に行くほど減少する
      Line := Words[I];                          // オーバーフローした単語が次の行を開始する
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // 最後の行をフラッシュする
end;

ループは、各単語を測定して合計するのではなく、試行行を測定します。なぜなら、行の幅はその単語の幅の合計ではないからです。単語間のスペースが寄与しており、測定された連続はそれを直接キャプチャします。貪欲なルール、つまり列が許す限り多くの単語をフィットさせ、最後にフィットしたところでブレークするというルールは、生のAddTextと実際の段落との間のギャップを埋めるのと同じルールです。描画の呼び出しは決して難しい部分ではありませんでした。それに先行しなければならない測定こそが難しい部分であり、ヘルパーが提供するのはまさにそれなのです

これが適合する場所

測定は、コンテンツの生成とレンダリングの間のレイヤーであるため、ゼロからのドキュメントワークフローの残りの部分と自然に組み合わさります。そもそもページを組み立ててテキストを配置している場合、その基礎はDelphiのPDFiumコンポーネントを使用してゼロからPDFドキュメントを作成するにあり、そこではAddTextとページ設定が完全にカバーされています。メトリクスは書体に依存するため、文字列と同じくらい測定するフォントが重要である場合、DelphiのPDFiumコンポーネントによるPDFフォントプロパティの分析では、ライブラリがそれらのバウンディングボックスを駆動するフォント情報をどのように報告するかが示されています。どちらも同じバインディング、すなわちDelphiおよびLazarus向けのPDFium Componentに基づいており、そこでは測定ヘルパーが、このブログ全体で説明されているドキュメント、ページ、およびテキストAPIとともに出荷されています