技術記事

DelphiでのHotPDF TextOut:サイズ、スタイル、回転、間隔

(72, 72)に描いた文字列は、ページ上端から1インチ下に着地します。コンテンツストリーム側からPDFを学んだ人ほど、これに引っかかります。そちらの世界ではy = 72は下端から1インチ上です。2つ目の驚きは数百行後に出てきます。US Letter向けに組んだレポートが、1ページ目だけ612 x 792ポイント、2ページ目以降は595 x 842で返ってくるのです。用紙サイズを設定した行が、正確に1回しか実行されなかったために

どちらの現象も根は同じです。PDFのテキストは、短い状態変更の連続(フォントとサイズ、文字間隔、単語間隔、位置と回転のテキストマトリクス)の後に1つのテキスト表示演算子が続く形で、高レベルのテキスト呼び出しはそのシーケンスの薄いラッパーにすぎません。呼び出しは状態を読みます。あなたのために状態をリセットすることはなく、新しいページは前のページの終わりの状態ではなく、固定されたデフォルト集合から始まります

TextOutが(X, Y)をページのどこに置くのか

HotPDFはTHPDFPage.TextOut(X, Y: Single; angle: Extended; Text: WideString)、つまりPdf.CurrentPage.TextOutとして届くメソッドでテキストを描きます。XとYはページの左上隅から測られ、Yは下向きに増えます。デフォルトのResolutionは72なので、1単位は1ポイント、すなわち1/72インチです。ページは各Yを、ページ高さから減算することで、下から上へ走るPDFユーザースペースへ変換します。angleの単位は度で、テキストはWideStringなのでUnicodeはそのまま通ります。すでにUTF-16バッファーを持つコードのために、PWORDと長さを取る2つ目のオーバーロードもあります

ページのジオメトリも固定されています。BeginDocが最初のページを追加し、BeginDocやAddPageが作るページはすべて、A4(595 x 842ポイント)と現在フォントArial 12で始まります。US Letter(612 x 792)へはCurrentPage.Size := Letterの1代入で届きますが、この代入はページに属するものであり、新しいページごとに繰り返す必要があります

デフォルトでは渡したYはテキストの上端を指し、ベースラインはその1フォントアセント下に来ます。TextFromBaseLine(True)に切り替えると、ページはベースライン位置決めになります。1行に異なるサイズを混ぜるものには、これを有効にしてください。上端モードでは、14ポイントの金額の横に置いた9ポイントの通貨コードは上端が金額の上端に揃うため、目に見えて浮きます。ベースラインモードなら、植字工が組むように、両方の実行が同じ線に座ります。次の例の最後のブロックはまさにそれをやっています

サイズとスタイルはSetFontが担う

TextOutにサイズやスタイルのパラメーターはありません。それらすべてを担うのはSetFont(FontName, FontStyle, ASize, FontCharset, IsVertical)で、最後の2つの引数はANSI_CHARSETと水平テキストがデフォルトです。設定したものは、次のSetFontまで、後続のすべてのTextOutに効き続けます。初日の混乱のほとんどはここにさかのぼれます。行が太字で出力されるのは、3つ前の呼び出しで何かが[fsBold]を設定し、それをクリアするものがなかったからです

var
  W: Single;
begin
  Pdf.BeginDoc;                                     // 最初のページ:A4、Arial 12
  Pdf.CurrentPage.TextFromBaseLine(True);           // 以降のYはすべてベースライン

  Pdf.CurrentPage.SetFont('Times New Roman', [], 24);
  Pdf.CurrentPage.TextOut(72, 72, 0, 'Quarterly Report');        // 24ポイントのレギュラー

  Pdf.CurrentPage.SetFont('Times New Roman', [fsBold], 12);
  Pdf.CurrentPage.TextOut(72, 104, 0, 'Revenue');                // 12ポイントのボールド

  Pdf.CurrentPage.SetFont('Times New Roman', [fsItalic], 11);
  Pdf.CurrentPage.TextOut(72, 120, 0, 'figures in thousands');   // 11ポイントのイタリック

  Pdf.CurrentPage.SetFont('Courier New', [fsBold, fsUnderline], 10);
  Pdf.CurrentPage.TextOut(72, 136, 0, '+18.4% YoY');             // スタイルの組み合わせ

  // 1つのベースラインにサイズを混ぜる:14ポイント書体が有効なうちに測る
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
  W := Pdf.CurrentPage.GetWideTextWidth('1,284.50');
  Pdf.CurrentPage.TextOut(72, 160, 0, '1,284.50');
  Pdf.CurrentPage.SetFont('Arial', [], 9);
  Pdf.CurrentPage.TextOut(72 + W + 3, 160, 0, 'EUR');            // 3ポイントの隙間
  ...
end;

スタイル引数はごく普通のTFontStylesセットです。[]はプレーン、[fsBold, fsItalic]は太字斜体です。fsUnderlineとfsStrikeOutは、実行の下や中央に薄い塗りつぶしバーとして描かれ、実行が回転してもそれに従います。サイズは座標と同じポイント単位なので、縦方向の算術は単純です。11ポイントの本文を16ポイントのステップで組めば、行間には約5ポイントの空気が残ります。自動の行送りはありません。すべてのYは自分で計算します。段落には面倒ですが、フィールドが固定座標に座るフォームにはちょうど良い仕組みです

GetWideTextWidthは、呼び出した時点で有効なフォントで測ります。したがって最後のブロックでは、14ポイントボールドがまだ設定されているうちに測定すべきです。2つ目のSetFontの後へ移動すると、9ポイントの接尾辞は金額の最後の桁の上に乗ります

フォント名は、PDFを生成するマシンにインストールされたフォントに対して解決されます。開発者のデスクトップで解決できる書体がビルドサーバーには存在しないことがあり、完成ファイルの書体はどのマシンで作ったかに依存します。スクリプトカバレッジももう半分です。ラテン語専用の書体にキリル文字やCJKを描かせると欠落グリフの箱が出ますが、これはフォントファイルの性質であって、呼び出しの性質ではありません。1つの文字列がどの単一書体もカバーしないスクリプトを混ぜるときは、TextOutWithFallbackが同じ4つの引数を取り、主フォントが欠く文字だけ実行ごとに書体を切り替え、最後に元のフォントへ戻します

Arial、Times New Roman、およびCourier Newを、いくつかの文字セットにわたってレギュラー、ボールド、イタリックのスタイルでレンダリングしたHotPDF TextOutページ

angle引数はテキストをどう回転させるのか

ゼロでないangleは、実行をそのアンカーの周りで反時計回りに指定した度数だけ回します。アンカーはテキスト自身の座標系でのテキストの左上隅です。アンカーは動きません。動くのはグリフが進む方向だけで、水平ラベルを置いた座標がその回転版も同じく配置します。回転した実行は、ページがベースラインモードでもこの左上アンカーを保ちます。TextFromBaseLineが寄せるのは水平の実行だけです

HotPDF TextOut の回転ジオメトリの図。固定したアンカーを中心に反時計回りで回転する 0 度、45 度、90 度の実行。背表紙ラベル、透かし、傾いた列見出しの Delphi TextOut 呼び出し例付き
非ゼロの角度は、動かないアンカー、すなわちテキストの左上隅を中心にランを反時計回りに回転させ、共通ベースラインは X を、水平行が Y を進めるのとまったく同じように進めます
var
  W, AxisMidY: Single;
begin
  AxisMidY := 400;                                // グラフY軸の中程

  // 90度の実行は下から上へ読まれ、Yから上向きに伸びる
  // だから軸で中央寄せするには、幅の半分ぶん中央より下から始める
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 11);
  W := Pdf.CurrentPage.GetWideTextWidth('Units sold');
  Pdf.CurrentPage.TextOut(40, AxisMidY + W / 2, 90, 'Units sold');

  // 斜めの透かし
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 60);
  Pdf.CurrentPage.TextOut(150, 250, 45, 'DRAFT');

  // 60度に傾けた列ヘッダー:Yは同じ、Xをステップ
  Pdf.CurrentPage.SetFont('Arial', [], 9);
  Pdf.CurrentPage.TextOut(120, 600, 60, 'Q1 actual');
  Pdf.CurrentPage.TextOut(160, 600, 60, 'Q2 actual');
end;

90度は軸ラベルと背表紙のタイトルを担います。90度ではグリフはアンカーのXの右側に1行の高さの幅で立ち、実行は自分の幅ぶんページ上端へ向かって登ります。だから中央寄せはYに幅の半分を加えるのであって引くのではないのですし、中点自体にアンカーを置いたラベルは完全にその上に座ります。60度は列ヘッダー向きで、長いラベルを狭い列の上に、隣にこぼれずに立たせられます。45度は定番の透かし角度です。ベースラインを共有する実行には同じYとステップするXを与えます。積み上げた水平行がXを共有してYをステップするのと同じことです

なぜ2ページ目はA4・Arial 12になるのか

AddPageは新しいページのインデックスを返してカレントにし、新しいページはデフォルトから始まります。A4、Arial 12、上端位置決め、文字間隔と単語間隔ゼロです。前のページから何も引き継がれません。この設計により各ページは自己完結します。PDFページのコンテンツストリームは、もともと新しいグラフィックス状態から始まるからです。ただしその一方で、ドキュメントの先頭でページを1度だけ設定するレイアウトコードは、2ページ目で静かに用紙サイズと書体を切り替えることになります。対策は、ページごとの設定をすべて1つのルーチンにまとめ、BeginDocの後とすべてのAddPageの後で呼ぶことです

HotPDF: US Letter ページの図。左マージン、上端の開始行、下マージンの上限へ向かって固定 Leading で進む行位置の算術と、手動 AddPage ガードと SetFont リセットを伴う名前付き定数レイアウトループを併記
名前付きアンカーがマジックナンバーの羅列を算術に変えます。走行中の Y を行ごとに Leading ずつ進め、下マージンは AddPage で自分で守ります
procedure StartReportPage(Pdf: THotPDF);
begin
  Pdf.CurrentPage.Size := Letter;               // 新しいページはA4で始まる
  Pdf.CurrentPage.TextFromBaseLine(True);       // 新しいページは上端モードで始まる
  Pdf.CurrentPage.SetFont('Arial', [], 11);     // 新しいページはArial 12で始まる
end;

procedure WriteReport(Pdf: THotPDF; Lines: TStrings);
const
  LeftMargin   = 72;   // 1インチ
  TopLine      = 72;   // 最初のベースライン、1インチ下
  BottomMargin = 72;
  Leading      = 16;
var
  I: Integer;
  Y: Single;
begin
  StartReportPage(Pdf);
  Y := TopLine;
  for I := 0 to Lines.Count - 1 do
  begin
    if Y > Pdf.CurrentPage.Height - BottomMargin then   // Letterでは720
    begin
      Pdf.AddPage;
      StartReportPage(Pdf);
      Y := TopLine;
    end;
    Pdf.CurrentPage.TextOut(LeftMargin, Y, 0, Lines[I]);
    Y := Y + Leading;
  end;
end;

TextOutの下にフローレイアウトはありません。ページの下端を越えたYも、コンテンツストリームにとっては有効な座標です。テキストは書かれ、メディアボックスの外に落ちるだけで、表示するビューアはありません。だからループは毎行の前にYを検査し、自分で新しいページを開きます。上限を720とハードコードせずCurrentPage.Heightから読むことで、後からこのヘルパーをA4に戻したときもループは正しいままです。同じ1インチのマージンならA4の上限は770になります

一番手っ取り早い確認は、読み手の印刷ダイアログがやることです。出力を開き、2ページ目へ進み、ドキュメントのプロパティを見ます。Letterページなら8.5 x 11インチ、A4ページなら約8.27 x 11.69インチと報告されます。1ページ目と2ページ目が食い違うなら、ページごとの設定がヘルパーの外に置かれたままです

文字間隔と単語間隔もページ状態の一部

文字列は正しいのに幅がおかしい、ということがあります。固定の罫線にまたがらせるべき見出し、数字を少し緩めると読みやすいコード、隙間を開けたいラベルの列です。ISO 32000-1にはこのための2つのテキスト状態パラメーターがあります。すべてのグリフの後に加算される文字間隔Tc(§9.3.2)と、各スペースで加算される単語間隔Tw(§9.3.3)です。HotPDFはこれらをSetCharacterSpacing(Spacing: Single)とSetWordSpacing(Spacing: Single)で設定します。デフォルト解像度でのポイント単位で、フォントと同じく、変更するまで効き続けます

HotPDF: 文字間隔 Tc が SUMMARY の全グリフの後に余分な空きを均等に広げるのに対し、単語間隔 Tw は空白文字のみに集約。後続段落への間隔状態の漏れを防ぐ設定、描画、リセットのサイクルに組み込まれる
Tc は調整をすべてのグリフに広げ、Tw は空白文字にだけ働きます。次の行でリセットすれば描画状態はきれいに保たれます

単語間隔には、仕様書に書き込まれた落とし穴があります。§9.3.3はTwをシングルバイトの文字コード32にだけ適用し、マルチバイトコードの内側のバイト32には明示的に適用しません。つまりこの演算子単体では、2バイトのグリフコードで書かれたテキストには何も作用しません。埋め込みUnicodeフォントは通常その方法で書かれます。HotPDFもUnicodeの実行をその方法で書くため、その経路では単語間隔を、各スペースグリフの後の明示的な位置調整として適用します。どちらの経路でもSetWordSpacingは隙間を広げるのです。引き金はやはり素のスペース、U+0020です。ASCIIスペースを1つも含まない中国語や日本語の実行は余分な空きをまったく得ず、全角スペース(U+3000)は普通のグリフとして扱われます

見出しを目標幅に届かせるには、文字間隔が適切な道具です。変更をスペースに寄せず均等に広げるからです。GetWideTextWidthは現在有効な文字間隔と単語間隔を織り込むので、両方をゼロにして測り、そこから間隔を導出します:

const
  Heading = 'SUMMARY';
  Target  = 200;        // 見出しがまたぐ罫線の幅(ポイント)
var
  Natural, Tc: Single;
begin
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
  Pdf.CurrentPage.SetCharacterSpacing(0);       // 残留間隔なしで測る
  Natural := Pdf.CurrentPage.GetWideTextWidth(Heading);

  // Tcはすべてのグリフの後に来るが、見えるのはグリフ間の隙間だけ
  Tc := (Target - Natural) / (Length(Heading) - 1);

  Pdf.CurrentPage.SetCharacterSpacing(Tc);      // 負の値は詰める
  Pdf.CurrentPage.TextOut(72, 72, 0, Heading);
  Pdf.CurrentPage.SetCharacterSpacing(0);       // 本文の前にリセット

  Pdf.CurrentPage.SetWordSpacing(6);
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(72, 100, 0, 'Name Department Extension');
  Pdf.CurrentPage.SetWordSpacing(0);
end;

リセット行は、設定行よりも重要です。1つの見出しの後に4ポイントの文字間隔を残すと、その下の全段落がその伸びを継承します。これは一見した校正では生き延び、印刷物になってから顔を出す、はっきりしない違和感として読み取られます。値を設定し、それを必要とする1つの実行を描き、次の行でゼロに戻す。そうすれば後続のコードは、前のセクションが何をしたかを知る必要がありません。同じページにはSetHorizontalScaling(Tz、デフォルトは100)があり、高さを変えずに実行を詰められます。SetTextRenderingModeには輪郭テキスト向けのtrStrokeなどの値があり、どちらもまったく同じ方法で持続します

水平方向のテキストスケーリング、文字間隔、単語間隔、および塗りつぶしとストロークのレンダリングモードを比較するHotPDF TextOutページ

出力の最初の1ページなら、HotPDF Hello Worldの例がドキュメントのセットアップと保存を扱います。列の端から端までテキストを満たす必要があるなら、PDFテキストの完全な両端揃えが独自の解説を持っています。縦書きのCJKはHotPDFの縦書きテキストレイアウトで扱います。上記のTextOut、SetFont、間隔の呼び出し、ページサイズ設定はすべて、DelphiとC++Builder向けのHotPDF Delphi Componentに同梱されています