技術記事

losLab PDF LibraryでDelphiによりRTFをPDFへ変換する

RTFはかなり以前から存在するので、誰も計画していなかった場所に現れます。レガシーなレポートジェネレーター、差し込み印刷のパイプライン、最新のワードプロセッサより前の法的文書アーカイブなどです。オンザフライでPDFへ変換するのは繰り返し発生する要件で、Windows上で実際に機能するアプローチは、専用のRTFパーサーではなく、Windows自身がTRichEditとEM_FORMATRANGEを通じて既に提供しているレンダリングパスです。losLab PDF Libraryは、そのパイプラインに直接スロットインする仮想デバイスコンテキストを公開しています

メカニズム:仮想 DC と EM_FORMATRANGE

Rich Editコントロールは、物理プリンターだけでなく、任意のデバイスコンテキストに対してコンテンツをページ分割できます。EM_FORMATRANGEメッセージは、指定されたDCに文字の範囲をレイアウトするようコントロールに指示し、収められた最後の文字の位置を返します。cpMinを毎回進めながらこれを繰り返し呼び出すと、ページごとの出力が得られます。losLab PDF LibraryのGetCanvasDCは、指定したページ寸法のインメモリDCを提供します。ページをそこへ描画した後、LoadFromCanvasDCが結果を新しい1ページのPDFドキュメントとしてキャプチャします。パイプラインはこれで全部です

先に正しく押さえるべきことが1つあります。TRichEditコントロールはターゲットページに合わせたサイズでなければなりません。コントロールがDCの寸法より小さいか大きいと、ページ分割はPDFに収まるものと一致しません。A4出力の標準的なやり方は、RTFファイルをロードする前に、コントロールのピクセル寸法を、後でLoadFromCanvasDCに渡すのと同じDPIで210 x 297 mmに一致させ、DCをサイズするのに使うのと同じミリメートルからピクセルへの変換を使うことです

PDF: RTF から PDF へのパイプライン。サイズ指定済み Rich Edit コントロールが EM_FORMATRANGE を通じてテキストを仮想キャンバス DC へ改ページし、各パスが 1 つの PDF ページとしてキャプチャ
EM_FORMATRANGE が RTF テキストの範囲を仮想キャンバス DC に組み、LoadFromCanvasDC が結果を捕らえます。最後の文字に届くまで繰り返します

Delphi の実装

以下では、ライブラリのActiveXエディション用のタイプライブラリインポート、PDFlibAX_TLBインポートユニットを使用しています。フォームにはTRichEditとボタンが配置され、フォームのOnCreateハンドラーがコントロールをサイズしてRTFをロードし、ボタンのクリックが変換ループを駆動します

unit MainUnit;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ComCtrls, PDFlibAX_TLB, ActiveX;

type
  TForm1 = class(TForm)
    RichEdit1: TRichEdit;
    Button1: TButton;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    function PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
      FirstChar: Integer): Integer;
  end;

var
  Form1: TForm1;
  PdfDoc: IPDFlibrary;

implementation

{$R *.dfm}

procedure TForm1.FormCreate(Sender: TObject);
begin
  PdfDoc := CoPDFlibrary.Create;
  // ページ分割がDCと一致するように、画面DPIでコントロールをA4サイズに
  RichEdit1.Width  := MulDiv(210, Screen.PixelsPerInch * 10, 254);
  RichEdit1.Height := MulDiv(297, Screen.PixelsPerInch * 10, 254);
  RichEdit1.Lines.LoadFromFile(
    ExtractFilePath(Application.ExeName) + 'document.rtf');
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Dc: HDC;
  PageNumber, LastChar, PdfDocId: Integer;
begin
  PageNumber := 1;
  LastChar   := 0;
  repeat
    // A4サイズの仮想DCを取得する
    Dc := PdfDoc.GetCanvasDC(
      MulDiv(210, Screen.PixelsPerInch * 10, 254),
      MulDiv(297, Screen.PixelsPerInch * 10, 254));
    // RTFコンテンツの次のページをDCへ描画する
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // DCの内容をPDFドキュメントとしてキャプチャする
    PdfDoc.LoadFromCanvasDC(Screen.PixelsPerInch, 0);
    PdfDocId := PdfDoc.SelectedDocument;
    PdfDoc.SaveToFile(
      ExtractFilePath(Application.ExeName)
      + 'Output' + IntToStr(PageNumber) + '.pdf');
    PdfDoc.RemoveDocument(PdfDocId);
    Inc(PageNumber);
  until LastChar = 0;
  // コントロールがDC用にキャッシュした整形データを捨てさせる
  SendMessage(RichEdit1.Handle, EM_FORMATRANGE, 0, 0);
end;

function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
  FirstChar: Integer): Integer;
var
  Fr: TFormatRange;
  Dpi, PageWidthTw, PageHeightTw, NextCharPosition: Integer;
begin
  // rcとrcPageはピクセルではなくtwips(1/1440インチ)。ページサイズを、
  // DCとコントロールをサイズしたのと同じDPIで変換する
  Dpi          := Screen.PixelsPerInch;
  PageWidthTw  := MulDiv(rtfBox.Width,  1440, Dpi);
  PageHeightTw := MulDiv(rtfBox.Height, 1440, Dpi);

  Fr.hdc         := hDc;
  Fr.hdcTarget   := hDc;
  Fr.rcPage      := Rect(0, 0, PageWidthTw, PageHeightTw);
  Fr.rc          := Fr.rcPage;  // ページ全体に描く。マージン分は縮める
  Fr.chrg.cpMin  := FirstChar;
  Fr.chrg.cpMax  := -1;

  NextCharPosition :=
    SendMessage(rtfBox.Handle, EM_FORMATRANGE, 1, LPARAM(@Fr));
  if NextCharPosition < Length(rtfBox.Text) then
    Result := NextCharPosition
  else
    Result := 0;  // 最後のページを合図する
end;

end.

ループの処理内容

PrintRtfBox は TFormatRange 構造体を埋め、SendMessage 経由で Rich Edit コントロールに渡します。コントロールは cpMin から始まる文字をレンダリングし、DC がいっぱいになると停止して、収まりきらなかった最初の文字の位置を返します。戻り値がテキストの全長以上になると、すべての文字がレンダリングされたことになり、関数はゼロを返します。これにより、repeat...until ループが終了します

各反復は、Output1.pdf、Output2.pdfなどの名前のPDFファイルを1つ生みます。代わりに単一の複数ページドキュメントが要るなら、AddToFileListとMergeFileListでページごとのファイルを後から組み立てられるし、ループを組み替えて、新しいページドキュメントをMergeDocumentで最初の1つへ折り畳んでもよい。上の反復ごとのSaveToFileに続くRemoveDocumentのパターンは、ピークメモリを1ページ分のコンテンツに抑えます。非常に長いRTFファイルでは、これが効いてきます

つまずきやすいサイズ設定の詳細

LoadFromCanvasDCのDPI引数は、あなたのピクセル何個で1インチかをライブラリに伝えます。ページはキャンバス幅÷DPI×72ポイントの幅で出てきて、高さも同じです。これを間違えると、画面上では画像が正しく見えていても、出力ではテキストが間違ったサイズで表示されます

rcとrcPageの単位は、この手法のほとんどのDelphiサンプルが間違える細部です。EM_FORMATRANGEは両方の矩形をtwips、つまり1/1440インチで読みます。描き込むDCがピクセルで働いていても、です。ピクセル値を流し込むと、コントロールは96 DPIで15倍小さいページにテキストを組みます。1パス数語で、10ページのドキュメントが何百ものほぼ空のPDFに化けるループです。MulDiv(Pixels, 1440, Dpi)で、DCをサイズしたのと同じDPIを使って変換すれば、改ページはキャンバスが実際に終わる場所に落ちます。rcPageはページ全体、rcはテキストが占められる領域なので、マージンはその内側へrcを縮めて付けます。これもtwipsで。ループが終わったら、nilのlParamでもう1回EM_FORMATRANGEを送り、コントロールにDC用にキャッシュした整形データを解放させます

PDF: EM_FORMATRANGE 向けに入れ子になった rcPage と rc 矩形。仮想 DC をサイズした DPI でキャンバスピクセルから twips へ変換して渡す
rcPage はページ全体、rc はテキストが使える領域です。両方とも twips で EM_FORMATRANGE へ渡し、下の DC はピクセルのままです

最後に、SendMessage の最初の呼び出しの前にウィンドウハンドルが有効になるように、FormCreate が実行されるときに、コントロールが既に表示されているフォームウィンドウにアタッチされている必要があります。実行時に動的に作成された TRichEdit は、フォームがまだ表示されていない場合、レンダリングループを開始する前に明示的な HandleNeeded 呼び出しを必要とします

フォントと RTF 機能の処理

レンダリングは Windows の Rich Edit エンジンによって行われるため、フォントの置換は、表示や印刷に使用されるのと同じルールに従います。RTF ファイルで参照され、マシンにインストールされているフォントは忠実にレンダリングされます。見つからないフォントは静かに置換されるため、行の長さやページ分割がずれる可能性があります。本番環境のバッチ変換では、これを明示的にテストする価値があります。RTF ソースで使用される各書体のドキュメントをロードし、出力ページ数が手動の印刷プレビューから期待されるものと一致することを確認してください

テーブル、埋め込み画像、そしてほとんどのRich Text整形機能は、Rich Editがネイティブに描画するので、特別な処理なしで機能します。驚き得る1つの領域は、twipsで表現されたカスタム段落間隔や一行目インデントを使うテキストです。Rich Editはtwips(1/1440インチ)で測り、TFormatRangeの矩形と同じ単位です。一方、その下のDCは現在のDPIでピクセルで働きます。コントロールは描画のとき両者を対応付けますが、RTFをプログラムで組み立てているなら、マージン値もtwipsになっていることを確認すべきです

DPI 認識と高 DPI ディスプレイ

150%スケーリング(144 DPI)のディスプレイでは、ミリメートルからピクセルへの変換は、100%のディスプレイより大きなピクセル数を返します。PDF LibraryはGetCanvasDCに渡されたピクセル寸法をすべて記録し、LoadFromCanvasDCのDPI引数でPDFの物理ページサイズを逆算します。渡すDPI値が、ミリメートルをピクセルへ変換するのに使ったものと一致しているかぎり、ディスプレイのスケーリングにかかわらず、出力ページサイズは正しい

アプリケーションがDPI非認識(古い既定)なら、Windowsは画面DCをスケーリングし、高DPIマシンではピクセル計算が狂います。最も簡単な修正は、アプリケーションマニフェストでDPI認識を宣言することです。アプリケーションは真のデバイスピクセルを受け取り、サンプルがLoadFromCanvasDCへ渡すScreen.PixelsPerInchの値は、100%を仮定する代わりに実際のディスプレイDPIを報告します

出力構造:ページごとに1つのファイルか、統合されたドキュメントか

上のループは各ページを別々のPDFファイルに書き込みます。それが欲しいものかどうかは、下流の用途次第です。レポート生成システムは、最終ドキュメントを後でページのマージや並べ替えで組み立てるため、個別ページを必要とすることがよくあります。最初から単一のPDFが要るなら、ライブラリで1セッションで組み立てられます。最初のLoadFromCanvasDC呼び出しが作るドキュメントのIDを保持し、以降の各パスではSelectDocumentでそれを選び、SaveToFileの代わりに新しいページのドキュメントIDでMergeDocumentを呼び、ループを抜けた後に1度だけ保存します。中間ファイルが不要になり、ほとんどの単一ドキュメント変換シナリオに適した構造です

PDF: 後で組み立てるためパスごとに 1 つの PDF ファイルを書くか、ループ内でページを追記して単一の複数ページ PDF を構築するかの選択
同じ描画ループが、個別の Output ファイルか、育ち続ける 1 つのドキュメントかに供給します。下流の柔軟性と中間ファイルの引き換えです

変換速度はページ数にほぼ比例し、200ページのドキュメントには数秒かかる可能性があるため、大きな RTF ファイルの場合は、ループ内に進行状況のフィードバックを追加する価値があります。repeat...until 構造は拡張が簡単です。反復ごとにプログレスバーの更新で文字オフセットを追跡し、LastChar を RichEdit1.GetTextLen からの合計文字数で割った値を使用します

ここで示したGetCanvasDCとLoadFromCanvasDCのメソッドは、DelphiとC++Builder向けのlosLab PDF Libraryの一部です