Technical Article

PDFiumを使用したN-upインポジションとページの並べ替え

結合(Merge)と分割(Split)は、誰もが最初に手を伸ばす2つのページ操作であり、多くの用途をカバーします。しかし、すべてをカバーできるわけではありません。ファイルを丸ごと移動するのではなく、ページを再配置する別の種類のタスクがあります。配布資料用に4枚のスライドを1枚の用紙に並べたり、ドキュメントの末尾のページを先頭にドラッグしたり、残りの部分に触れることなく3、7、12ページ目を短い抜粋として抽出したりする操作です。PDFiumはまさにこれらのために3つのメソッドを公開しており、それぞれがすでにご存知の結合や分割とは異なる挙動を示します。本記事では、それらのメソッドが何を行うか、出力されるポイントがどこにあるか、そして現場でクラッシュを引き起こした所有権に関する1つの詳細について説明します。

3つとは、N-upインポジションのためのImportNPagesToOne、インプレースでの並べ替えのためのMovePages、そしてサブセット抽出のためのImportPagesByIndexです。結合はドキュメントを端から端までスタックし、ページ数は入力の合計と等しくなります。分割は1つの入力から複数の出力ファイルを書き出します。ここで紹介する3つの操作はその中間に位置します。すなわち、1つは1枚の用紙を共有するソースページの数を変更し、もう1つは単一ドキュメント内の順序を変更し、最後の1つは選択した一部のページを別のドキュメントにコピーします。どれがどれであるかを知ることで、1回の呼び出しで済む場所に、結合して削除するという面倒な処理を無理に適用する手間を省くことができます。

N-upインポジションが実際に行うこと

インポジション(面付け)とは、印刷して折りたたんだ結果が正しい順序で読めるように、複数のソースページを1枚の大きな用紙に配置する印刷業界の用語です。日常的な用途としては、2面割付の配布資料、4面割付の小冊子の折りシグネチャ、または1ページに多数のサムネイルを収めるコンタクトシートなどがあります。PDFiumは、1回の呼び出しでこのジオメトリを処理します。

function ImportNPagesToOne(
  OutputWidth, OutputHeight: Single;
  NumX, NumY               : Cardinal): TPdf;

NumXNumYはグリッドを表します。2, 1は2つのソースページを左右に配置し、2, 2は4つのページを4分割レイアウトに詰め込み、4, 3は12面割付のコンタクトシートを作成します。PDFiumはソースページを順番に読み取り、各ページをセルに合わせて縮小し、グリッドを左から右、上から下へと埋めていき、現在のグリッドがいっぱいになると新しい出力用紙を開始します。元のソースページは変更されません。戻り値として受け取るのは、ページが合成された新しいドキュメントです。

出力サイズはピクセルではなくポイント単位

OutputWidthOutputHeightはPDFのユーザーユニットであり、PDFのユーザーユニットは1ポイント、すなわち72分の1インチです。このユニットは出力用紙の物理的なサイズを宣言するものであり、スクリーンのピクセル数やレンダリングのDPIとは一切関係ありません。これはインポジションでミスが発生する最も一般的な箇所です。ビットマップに慣れた開発者がピクセル数を使用してしまい、結果として切手サイズや看板サイズの用紙になってしまうためです。

覚えておくべきなのは、最もよく使われる2つの用紙サイズの値です。USレター(US Letter)は612×792ポイントです。なぜなら8.5インチ×72は612で、11インチ×72は792だからです。A4は、210×297ミリメートルの寸法から、おおよそ595×842ポイントになります。バインディングのヘッダーには、1ユニットは72分の1インチであるというルールが明確に記載されており、コード内で数値を直書きする代わりにインチからサイズを計算したい場合に使える、72に等しいPointsPerInch定数も提供されています。

const
  LetterW = 612.0;   // 8.5 in * 72
  LetterH = 792.0;   // 11  in * 72
var
  Source, Composite: TPdf;
begin
  Source := TPdf.Create(nil);
  Composite := nil;
  try
    Source.FileName := 'slides.pdf';
    Source.Active := True;

    // Four source pages per Letter sheet, 2 by 2 grid.
    Composite := Source.ImportNPagesToOne(LetterW, LetterH, 2, 2);
    if Composite = nil then
      raise Exception.Create('PDFium rejected the imposition arguments');

    Composite.SaveAs('slides-4up.pdf');
  finally
    Composite.Free;   // see the next section: this is mandatory
    Source.Free;
  end;
end;

返されたハンドルは呼び出し側が解放する

シグネチャをもう一度読んでください。ImportNPagesToOneはBooleanではなくTPdfを返します。この戻り値は、ソースとは別に割り当てられた真新しいドキュメントハンドルであり、呼び出し側がそれを所有します。メソッドを呼び出した元のTPdfは変更されず、引き続き自身のハンドルを所有します。合成ドキュメントは、2つ目の独立したオブジェクトです。返されたTPdfを解放せずにスコープから外してしまうと、PDFiumドキュメント全体がリークします。

より危険なミスは、その逆を行うことです。内部的に、このメソッドはFPDF_ImportNPagesToOneを介してPDFiumから新しいFPDF_DOCUMENTを要求し、その生のハンドルを返されるTPdfの内部にラップすることで、ラッパーの生存期間がハンドルの生存期間を支配するようにします。その時点から、ハンドルの所有者は正確に1つとなり、閉じるべき場所も正確に1つ(返されたオブジェクトをFreeするとき)になります。ラッパーを解放しつつ、キャプチャした生のハンドルに対してFPDF_CloseDocumentも呼び出すような不注意なエラーパスは、同じPDFiumドキュメントを2回閉じることになります。これは二重解放(double-free)であり、以前呼び出し側を困らせた特定のバグそのものです。それを防ぐルールはシンプルです。メソッドから渡されたTPdfを解放することによってのみ、単一のパスでドキュメントをクローズし、すでに養子となったハンドルを閉じるためにラッパーを越えてアクセスしないことです。

これに付随して2つの結論が導かれます。1つ目は、グリッド軸のいずれかがゼロである場合やメモリ割り当て失敗など、PDFiumが引数を拒否したときにメソッドはnilを返すため、結果を処理する前にnilチェックを行う必要があります。2つ目は、上記のサンプルのように、tryの前に出力変数をnilで初期化し、finallyでそれを解放することです。これにより、途中で失敗しても未定義の参照を解放したり、解放処理自体をスキップしたりするのを防ぐことができます。

ページを書き換えずに並べ替える

インポジションは新しいドキュメントを作成します。並べ替えは、1つのドキュメントをインプレースで変更します。MovePagesは、一連のページを現在の位置から持ち上げて移動先にドロップし、移動されたブロックの周囲のすべてのページをずらすことで、ページ数を一定に保ちます。

function MovePages(
  const PageIndices: array of Integer;
  DestPageIndex    : Integer): Boolean;

インデックスはゼロベースです。PageIndicesは移動するページを最終的な並び順でリストし、DestPageIndexは移動完了後に最初の移動ページが配置されるインデックスです。PDFiumはコンテンツのコピーや再圧縮を行うのではなく、ページを再配置(リロケート)するだけであるため、この操作は低コストかつロスレスです。ページオブジェクトは元のストリーム、リソース、および再現性を維持します。これは、ユーザーがサムネイルを新しいスロットに引っ張り、あなたが1回の移動で新しい順序をコミットするような、「ドラッグによるページ並べ替えパネル」の背後にある呼び出しです。インデックスが範囲外の場合にはFalseを返すため、並べ替えが成功したと仮定せずに結果を検証してください。

var
  Doc: TPdf;
begin
  Doc := TPdf.Create(nil);
  try
    Doc.FileName := 'report.pdf';
    Doc.Active := True;

    // Move the last page (index 4 in a 5-page file) to the very front.
    if not Doc.MovePages([4], 0) then
      raise Exception.Create('MovePages rejected the index');

    Doc.SaveAs('report-reordered.pdf');
  finally
    Doc.Free;
  end;
end;

インデックスによるサブセットの抽出

3つ目の操作は、あるドキュメントから指定されたページ群を別のドキュメントにコピーします。ImportPagesByIndexは、ソースドキュメントとゼロベースのインデックス配列を受け取り、ターゲットドキュメントの選択された位置にそれらのページを挿入します。

function ImportPagesByIndex(
  Source           : TPdf;
  const PageIndices: array of Integer;
  InsertAt         : Integer= 0): Boolean;

ターゲットドキュメントに対してこのメソッドを呼び出し、最初の引数としてソースを渡します。PageIndicesは抽出するソースページを並び順通りに指定します。InsertAtはターゲット内でインポートされた最初のページが挿入されるゼロベースのスロットで、0を指定すると既存の最初のページの前に配置され、ターゲットの末尾に付加されます。空の配列を指定するとすべてのページがインポートされ、必要に応じて完全なコピーを作成できます。ソース内でインデックスが範囲外である場合はFalseを返します。

ここで分割との対比が重要になります。分割は個別のファイルを書き出し、1回の操作でディスク上に多くの出力を生成します。ImportPagesByIndexは逆の処理を行います。メモリ内の単一のターゲットドキュメントに選択されたページ群を集約し、それを一度だけ保存します。「3、7、12ページを1つの短いPDFとして抽出したい」というタスクの場合、これが直接的なルートであり、内部的にはFPDF_ImportPagesByIndexをラップしています。

var
  Source, Excerpt: TPdf;
begin
  Source := TPdf.Create(nil);
  Excerpt := TPdf.Create(nil);
  try
    Source.FileName := 'manual.pdf';
    Source.Active := True;
    Excerpt.CreateDocument;   // start an empty target

    // Pull pages 3, 7 and 12 (zero-based 2, 6, 11) into the excerpt.
    if not Excerpt.ImportPagesByIndex(Source, [2, 6, 11], 0) then
      raise Exception.Create('A requested page index is out of range');

    Excerpt.SaveAs('manual-excerpt.pdf');
  finally
    Excerpt.Free;
    Source.Free;
  end;
end;

クリーンにまとめる

最初から最後までの形は3つの操作すべてで同じです。FileNameを設定し、ActiveTrueに切り替えてソースを開き、操作を実行し、SaveAsで保存し、自身が所有するオブジェクトを解放します。注意が必要な分岐は、どの呼び出しが新しいドキュメントを割り当てるかという点です。MovePagesはすでに保持しているドキュメントを変更するため、解放するオブジェクトは1つだけです。ImportPagesByIndexは自身で作成したターゲットに書き込むため、ソースと開いたターゲットの両方を解放します。ImportNPagesToOneは例外的な存在で、新しいドキュメントは自身で構築したものではなくメソッドの戻り値であり、それが個別の呼び出し側所有のハンドルであることを忘れることが、リークと二重解放の両方の原因になります。結果をnilで初期化し、呼び出し後にチェックして、単一のパスで解放してください。

ページを並べ替えるのではなく、ファイル全体を結合するタスクを行いたい場合は、複数のPDFファイルを1つのドキュメントに結合するを参照してください。その逆で、1つのドキュメントを複数のファイルに分割したい場合は、PDFドキュメントを複数のファイルに分割するを参照してください。ここで説明したインポジションおよび並べ替えメソッドは、このブログの他の場所で扱われているロード、レンダリング、および編集APIと並んで、DelphiおよびC++Builder向けのPDFium Componentの一部として提供されています。