技術記事

Delphi HotPDF Hello World: 初めてのPDFプログラム

仕様の視点から書かれた最初の1行は、y = 770に着地します。紙の上では理屈は筋が通っています。ISO 32000-1はユーザー空間の原点を左下に置き、A4ページは842ポイントの高さだから、770は上端から1インチ下です。それを上から測るコンポーネントに対して走らせると、挨拶は下端から約1インチ上に着地し、ページの残りは上に空いたままになります。2ページ目を足すと、入念に選んだフォントは12ポイントのArialに戻ります。どちらの驚きも、すべてのPDFプログラムが最初のページで下す2つの決定、つまりどのフォントが現在か、どちらの隅から数えるかから来ます

Delphiにおける4呼び出しのHotPDF Hello Worldプログラムフローの図。左上のページ原点と2つのセットアップの罠を強調
作成、開始、描画、終了が全体の背骨であり、左上原点であることが後続の行ほどY値が大きくなる理由を説明します

Delphiにおける最小のHotPDFプログラム

HotPDFのHello Worldは、固定の順序で4つの呼び出しを行うコンソールプログラムです。ドキュメントプロパティの設定、BeginDoc、SetFontとTextOutによる描画、そしてEndDoc。他はすべて普通のDelphiです。11の言語で読者に挨拶する1ページを書くプログラムがこれです:

program HelloWorld;

{$APPTYPE CONSOLE}

uses
  Winapi.Windows,   // DEFAULT_CHARSET用。pre-XE2のDelphiでは素のWindowsを使う
  HPDFDoc;          // HotPDFのメインユニット

procedure CreateHelloWorld(const FileName: string);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    // ドキュメント全体の設定。これらはすべてBeginDocの前に
    Pdf.FileName := FileName;
    Pdf.AutoLaunch := False;            // 既定はTrue。EndDocの後にビューアーを開く
    Pdf.Compression := cmFlateDecode;   // 既定はcmNone
    Pdf.FontEmbedding := True;          // すでに既定値。読者のため明示
    Pdf.EnableFontSubsetting := True;   // 実際に描いたグリフだけを埋め込む
    Pdf.BeginDoc;                       // ページ1(A4)を作る

    // 第4引数の既定はANSI_CHARSET。DEFAULT_CHARSETを明示的に渡す
    Pdf.CurrentPage.SetFont('Arial Unicode MS', [], 14, DEFAULT_CHARSET);

    // X、Yは左上隅からのポイント単位。Yは行の上端
    Pdf.CurrentPage.TextOut(72,  72, 0, 'Hello, Delphi PDF world!');      // 英語
    Pdf.CurrentPage.TextOut(72, 102, 0, 'Hola, mundo Delphi PDF!');       // スペイン語
    Pdf.CurrentPage.TextOut(72, 132, 0, 'Hallo, Delphi PDF Welt!');       // ドイツ語
    Pdf.CurrentPage.TextOut(72, 162, 0, 'Bonjour, monde PDF Delphi!');    // フランス語
    Pdf.CurrentPage.TextOut(72, 192, 0, 'Ciao, mondo Delphi PDF!');       // イタリア語
    Pdf.CurrentPage.TextOut(72, 222, 0, 'Olá, mundo Delphi PDF!');        // ポルトガル語
    Pdf.CurrentPage.TextOut(72, 252, 0, 'Здравствуйте, Delphi PDF мир!'); // ロシア語
    Pdf.CurrentPage.TextOut(72, 282, 0, 'こんにちは、Delphi PDFの世界!');    // 日本語
    Pdf.CurrentPage.TextOut(72, 312, 0, 'Merhaba, Delphi PDF dünyası!');  // トルコ語
    Pdf.CurrentPage.TextOut(72, 342, 0, '你好,Delphi PDF世界');           // 中国語
    Pdf.CurrentPage.TextOut(72, 372, 0, '여보세요, Delphi PDF 세계!');      // 韓国語

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

begin
  CreateHelloWorld('HelloWorld.pdf');
  Writeln('Wrote HelloWorld.pdf');
end.

DelphiのHotPDFコンポーネントによって生成された多言語Hello World PDF

THotPDF.Create(nil)とtry/finally ... Freeの組み合わせは、いつものDelphiの所有権パターンで、PDFの生成はそれを変えません。行はY = 72から下へ、30ポイント間隔で進みます。14ポイントのテキストには心地よい行間です。走らせると、作業ディレクトリにHelloWorld.pdfが現れます。1ページ、圧縮済みのコンテンツ、サブセットとして埋め込まれたフォント

ドキュメントプロパティはBeginDocの前に

BeginDocはドキュメントを開始し、最初のページを作るので、ファイル全体を形づけるものはすべてその前に割り当てます。HotPDFはこれを慣習に任せず、一部のプロパティでは強制します。BeginDocの後にCompressionを割り当てると、セッターはCannot set Compression value - document in progress.というメッセージの例外を上げます。AutoLaunchも同じ方法で守られています。うるさいのはここでは良いことです。ドキュメント設定をすべてBeginDocの上の1つのブロックに置けば、そのメッセージは二度と見ません

既定値のうち2つはもう一度見ておく価値があります。CompressionはcmNoneで始まるので、触れないプログラムは非圧縮のコンテンツストリームを書きます。テキスト1ページならただの無駄ですが、数百ページのレポートなら、誰もが最初に気づくファイルサイズの正体です。AutoLaunchはTrueで始まり、コンソールツールはEndDocが戻った瞬間に完成PDFを関連付けられたビューアーで開きます。机の上では心地よく、サービスの中、スケジュールジョブ、ビルドエージェントの中では間違いです。そういう環境ではコードでFalseに設定します。コードを変えられないCIの実行では、環境変数HOTPDF_SUPPRESS_AUTO_LAUNCHを1にしても起動を止められます

フォントの扱いは、私たちが立場を取るところです。FontEmbeddingは既定でTrueなので、書体はファイルと一緒に旅し、Arial Unicode MSが一度もインストールされたことのないマシンでも描画されます。一方EnableFontSubsettingは、編集ワークフローのために完全フォントを残しておく既定ではオフです。生成する挨拶、請求書、レポートには、サブセット化をオンにしてください。Arial Unicode MSは広大な範囲のスクリプトをカバーするので、11の短い行を表示するために書体全体を埋め込むと、見た目の利益なしにファイルがメガバイト単位で大きくなります。下流の誰かがテキストを編集し、あなたが描かなかったグリフを必要とする場合にだけ、オフのままにします

HotPDFページの(0, 0)はどこにあるのか

HotPDFはページ座標を左上隅から測り、Yは下向きに増えます。VCLのTCanvasと同じ枠組みです。既定のResolutionである72では、1単位は1ポイント、つまり1/72インチです。PDFファイル自体は、ISO 32000-1 §8.3.2.3が要求するとおり、下から上への座標を保存し続けます。HotPDFは、あなたが渡すすべてのYを、コンテンツストリームを書くときにページの高さから引いて変換します

冒頭の例の算数は短いです。BeginDocとAddPageが作る既定ページはA4、595 x 842ポイントです。上端から1インチに置きたい行は、HotPDFの言葉ではY = 72、生のPDFの言葉ではy = 770です。842 − 72 = 770だからです。770をTextOutに渡すと、テキストは上から770ポイント下へ行き、下端の72ポイント上に着地します

同じ呼び出しには、もう1つ小さな罠があります。既定では、渡したYはテキストの上端を指し、HotPDFはベースラインを置く前にテキストの高さぶん下がります。GDIから移植されたコードは、たいていまさにそれを期待します。別のPDFライブラリや自分のコンテンツストリームライターから移植されたコードは、たいていYがベースラインであると期待し、すべての行が計画より1テキスト高さぶん低く出ます。THPDFPage.TextFromBaseLine(True)が現在のページをベースライン位置に切り替えます:

// 上から下(ボトムアップ)ソース、つまりPDFレイアウトや
// 仕様の図や他ライブラリ由来の座標のためのブリッジ。この記事用に書いたヘルパーで、
// HotPDFの一部ではない。既定のResolution 72で有効
function FromBottomUp(Page: THPDFPage; PdfY: Single): Single;
begin
  Result := Page.Height - PdfY;
end;

procedure DrawFromPdfCoordinates(Pdf: THotPDF);
begin
  Pdf.CurrentPage.SetFont('Arial', [], 12);
  // Yはベースラインを指す。PDF自身のテキストオペレーターがグリフを置くやり方だ
  Pdf.CurrentPage.TextFromBaseLine(True);
  // 下端から770ポイント上のベースライン。つまり上からはY = 72
  Pdf.CurrentPage.TextOut(72, FromBottomUp(Pdf.CurrentPage, 770), 0,
    'Baseline one inch below the top edge');
end;

Resolutionは、数字の意味を変えるもう1つの設定です。THotPDF.Resolutionを96にすると、X/Y座標、マージン、TextWidthの結果は1/96インチで表されます。レイアウトが画面ピクセルで設計されているときに便利です。ただしページのWidthとHeightはポイントを報告し続けるので、ページ境界と描画座標を混ぜるレイアウトコードは、XとYと同じ単位で返ってくるCurrentPage.UserWidthとCurrentPage.UserHeightを読むべきです。72なら2組は一致します。ミリメートルで考えたいなら、MeasurementUnitプロパティ(既定はmuPoints、muMM、muInches、muCentimeters、muTwips、muPixelsが利用可能)の方がきれいな切り替えです。プログラムごとに1つの規約を選ぶ。決して積み重ねない

なぜ2ページ目は12ポイントのArialで出るのか

新しいページはどれも、知られた状態から始まるからです。THotPDF.AddPageは新しいページを現在にして、A4にサイズし、そこにSetFont('Arial', [], 12)を呼びます。Resolutionが何であれサイズは12ポイントのままです。BeginDocは同じ経路を通ってページ1を作るので、Hello WorldがフォントをBeginDocの直後に設定するのはそのためで、前に設定するのではありません。複数ページ出力への帰結は見逃しやすいです。ページ1で呼んだSetFontは引き継がれません。2ページ目のラテン文字の行は、ただ小さいだけでArialのままだから見た目は無事です。CJKの行が決め手で、Arialはそれらのグリフを持たないので、リーダーは空のボックスを表示するか、何も表示しません

予測可能な開始フォントは、ページオブジェクトとして正しい設計です。ページ5の描画コードが、ページ4がたまたま残したものに黙って依存しないで済みます。代償はページ設定の繰り返しで、それを繰り返す最もきれいな方法は、最初のページを含むすべてのページが通る1つのプロシージャです:

uses
  Winapi.Windows, Vcl.Graphics, HPDFDoc;

// 自分用のヘルパー。新しいページに何が必要かを知る場所を1つに
procedure PreparePage(Pdf: THotPDF; IsFirst: Boolean);
begin
  if not IsFirst then
    Pdf.AddPage;                       // フォントをArial 12へリセットする
  Pdf.CurrentPage.SetFont('Arial Unicode MS', [], 14, DEFAULT_CHARSET);
end;

procedure WriteGreetings(Pdf: THotPDF; Lines: array of string);
const
  TopMargin = 72;
  Leading   = 30;
  LinesPerPage = 20;
var
  I, Row: Integer;
begin
  PreparePage(Pdf, True);              // ページ1はBeginDocの後ですでに存在する
  Row := 0;
  for I := 0 to High(Lines) do
  begin
    if Row = LinesPerPage then
    begin
      PreparePage(Pdf, False);
      Row := 0;
    end;
    Pdf.CurrentPage.TextOut(72, TopMargin + Row * Leading, 0, Lines[I]);
    Inc(Row);
  end;

  // 太字見出しには専用のSetFontが必要。その後本文フォントへ戻す
  Pdf.AddPage;
  Pdf.CurrentPage.SetFont('Arial Unicode MS', [fsBold], 18, DEFAULT_CHARSET);
  Pdf.CurrentPage.TextOut(72, 72, 0, 'Appendix');
  Pdf.CurrentPage.SetFont('Arial Unicode MS', [], 14, DEFAULT_CHARSET);
  Pdf.CurrentPage.TextOut(72, 110, 0, 'Body text after the heading');
end;

ページがTextFromBaseLineでベースライン位置にも切り替えるなら、その呼び出しもフォントの隣にPreparePageへ入れてください。ページごとの設定をすべて1つのプロシージャに保つと、新しい要件、色でも違うサイズでも、1か所に足して全ページに届きます

1つのフォントで多くのスクリプト、そしてその限界

1回のSetFontでラテン、キリル、日本語、中国語を1ページに運べるのは、Arial Unicode MSが非常に広い範囲のスクリプトのグリフを1つの書体に束ねているからです。SetFontの第4引数はWindowsの文字セットで、既定はANSI_CHARSETです。これはフォント選択を西欧コードページに縛ります。DEFAULT_CHARSET(値1)を渡すと、代わりにWindowsが名前で書体を選べます。多言語ページが望むのはこちらです

フォントは、PDFを組み立てるマシンに存在しなければなりません。HotPDFはWindowsが名前を解決したフォントを何であれ埋め込み、Arial Unicode MSが見当たらないと、Windowsは文句なしに別の書体へ置き換えます。出力はその代替書体を埋め込み、その書体がカバーしないスクリプトの行は空の箱として出ます。これが顔を出すのはたいていビルドサーバーで、そこには誰もOfficeフォントを入れていないからです。バッチ実行を任せる前に、新しいマシンが作る最初のファイルの埋め込みフォント一覧を確認してください

1つの書体ではすべての文字列をカバーできないとき、TextOutWithFallbackはTextOutと同じ引数を受け取り、文字ごとにフォントを解決し、各ランを描ける書体で描き、その後あなたの元のフォントを復元します。アラビア語やヘブライ語のような右から左のスクリプトは、グリフのカバー以上を要ります。形成と逆順のランで、これはRtLTextOut関数の領域で、たいていRegisterUnicodeTTFでロードしたTrueTypeファイルと組にされます

ここからの自然な次のステップは、テキストのサイズ、スタイル、回転、そして1ページへの複数ブロックの意図的な配置です。TextOutサンプルがこれらを歩みます。罫線、ボックス、画像は同じCurrentPageオブジェクトと同じ左上座標を使うので、原点とページごとの設定についての上の話はすべて、そのまま当てはまります

このHello Worldが自分のフォントできれいに走ったら、HotPDF Delphi Componentのページが、図形や画像からフォームまで、描画APIの残りをカバーします