技術記事

DelphiのPDFiumコンポーネントを使用してPDFドキュメントを分割する

最初のトップレベルブックマークがページ 5 を指す 40 ページのユーザーマニュアルで、「このブックマークから次のブックマークまで」で切るスプリッターは、合計 36 ページのチャプターファイルを書き出します。表紙、著作権ページ、2 ページの目次が、すべてのアウトライン項目の前に座っているので、どのファイルにも着地しないのです。アウトラインはセクションがどこから始まるかを示すだけです。最初の項目より前はカバーせず、形式は項目がページ順に列挙されることを何も要求しないのです。だから信頼できるスプリッターは、ブックマークのページを切れ目として集め、ソートし、前付けの明示的な範囲を足します

すべての分割モードの背後にある 1 つのループ

PDFium Component は、境界を何が決めるかにかかわらず、2 つ目の TPdf 上で同じ 4 工程で文書を分割します。CreateDocument がメモリ上に空の PDF を始め、ImportPages がソースからページ範囲をそこへコピーし、SaveAs がファイルを書き、Active := False が出力を再び閉じます。ページごとの分割、固定サイズの塊、ブックマークセクションの違いは、ImportPages に渡す範囲文字列だけです

各パスの最後のリセットは省略できません。CreateDocument は、まだアクティブなコンポーネント上では実行を拒み、メッセージが "Cannot perform this operation on an active" で始まる EPdfError を上げます。だから Active := False を忘れたループの 2 回目の反復は、ちょうど 1 個のファイルが書かれた後に例外で止まります。リセットを finally ブロックに入れると、失敗したインポートや保存が、次のパスに向けて出力を開いたままにするのを防げます。1 つの TPdf インスタンスが全走行に仕え、メモリに保持される出力文書は常に 1 つです

Delphi における PDFium Component の分割ループの図。CreateDocument、読み取り専用のソースからの ImportPages、結果を確認する SaveAs、そして各新しい反復の前の Active リセット
グループを決めるのが何であれ、ループは同じです。ページをインポートし、結果を確認しながら保存し、それから Active をリセットして、次の CreateDocument がきれいな状態から始まるようにします

ソースを開くことには、それ専用の検査が値します。Active := True の代入は決して raise しません。ファイルがない、壊れている、供給されていないパスワードを必要とするとき、TPdf はロードエラーを捕まえて Active を False に置きます。ImportPages も自分でソースのアクティブ化を試みるので、開かれていないソースは、最初のページの False 復帰としてしか現れず、原因のヒントは出ません。明示的に開いて Active を検査すれば、それが、出力が何も存在する前に、ファイル名を告げるエラーになります

procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
  Source, PdfOut: TPdf;
  I: Integer;
  OutFile: string;
begin
  Source := TPdf.Create(nil);
  PdfOut := TPdf.Create(nil);
  try
    Source.FileName := SourceFile;
    Source.Active := True;               // ロード失敗は Active = False を残す
    if not Source.Active then
      raise Exception.Create('Cannot open ' + SourceFile);

    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;             // PdfOut がまだアクティブの間は raise する
      try
        // 範囲は 1 始まり。位置 1 は空の文書の先頭
        if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
          raise Exception.CreateFmt('Cannot import page %d', [I]);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('page_%.4d.pdf', [I]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;          // 次のパスの前に出力を閉じる
      end;
    end;
  finally
    PdfOut.Free;
    Source.Free;
  end;
end;

ImportPages の第 3 引数は、最初にインポートされるページが着地する、宛先の 1 始まりの位置です。空の文書では 1 が唯一の位置です。範囲構文そのもの(単一ページ、'5-10' のような閉スパン、カンマリスト)と、マージされた束を逆にする位置の間違いは、複数の PDF ファイルを 1 つの文書へマージするで扱います。同じ API の逆向きです

固定サイズの塊と、呼び出し側が指定する範囲

N ページごとに文書を切るのは数えの問題で、注意が要るのは末尾だけです。230 ページのスキャンを 25 ページずつに切ると、完全な 9 部と、5 ページの 10 部目になります。だから各スパンの終わりは Source.PageCount に切り詰められます

procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
  PdfOut: TPdf;
  FirstPage, LastPage, Part: Integer;
  Range, OutFile: string;
begin
  if ChunkSize < 1 then
    raise Exception.Create('ChunkSize must be at least 1');
  PdfOut := TPdf.Create(nil);
  try
    Part := 0;
    FirstPage := 1;
    while FirstPage <= Source.PageCount do
    begin
      LastPage := FirstPage + ChunkSize - 1;
      if LastPage > Source.PageCount then
        LastPage := Source.PageCount;    // 最後の部分はたいてい短い
      if FirstPage = LastPage then
        Range := IntToStr(FirstPage)     // 1 ページの末尾は素の数値で
      else
        Range := Format('%d-%d', [FirstPage, LastPage]);
      Inc(Part);

      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source, Range, 1) then
          raise Exception.Create('Cannot import pages ' + Range);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('part_%.3d.pdf', [Part]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
      FirstPage := LastPage + 1;
    end;
  finally
    PdfOut.Free;
  end;
end;

ユーザーが打ち込んだ範囲は話が別です。ImportPages は悪い範囲を復帰値で報告します。Source.PageCount を超えるページ番号、ゼロ、逆転したスパン、パースできないテキストは、呼び出しを False にし、その文字列の中のページは 1 つもコピーされません。ループの内側では、その失敗は、3 つのセクションファイルがすでにディスクにある後に、4 番目の項目として届きます。最初の CreateDocument の前に、すべての項目を Source.PageCount に対して確認すれば、数行のパースで済み、4 番目の項目の打ち間違いが何も書かない、やり直す人の期待する振る舞いになります

選択がすでに整数配列に住んでいるとき、たとえばサムネイルビューで選ばれたページは、ImportPagesByIndex(Source, PageIndices, InsertAt) が文字列の工程を飛ばします。インデックスも InsertAt も 0 始まりなので、範囲として '1,3-5' と読める同じ選択は、[0, 2, 3, 4] になります

ブックマークセクションが短くなったり消えたりする理由

ブックマークセクションは、コードがアウトラインの整いを信用するときに壊れます。TPdf.Bookmarks は、作成者が並べた順でトップレベルの項目を返します。それぞれ Title と 1 基まりの PageNumber を持つ TBookmark レコードです。現実のファイルには、素朴な「この項目から次の項目まで」の規則を壊すものが 4 つあります

  • アウトライン項目は、ターゲットを /Dest エントリとして運ぶか、/A の GoTo アクションとして運びます(ISO 32000-1 §12.3.3 と §12.6.4.2)。PageNumber は /Dest から埋められます。アクションベースの項目では 0 のまま、ページは代わりに ActionPageNumber に入ります
  • アウトラインの順序は、作成者が見た読み順です。2 番目に置かれた「索引」の項目や、章の前に列挙された付録は、「次のブックマークから 1 を引く」を現在の開始より小さくし、そのセクションは消えます
  • 2 つのトップレベル項目が同じページを指せます。典型的には、部のタイトルとその最初の章です
  • ページを、アウトラインに手を付けないツールで削除したとき、項目は最後のページの先を指せます
Delphi で PDFium Component で分割するとき、トップレベルの PDF ブックマークを計算されたページ範囲と出力ファイルへ対応させる図。範囲外のブックマークがスキップされる様子を含む
セクションは、各トップレベルブックマークのページから、次のブックマークの 1 ページ手前まで走ります。末尾の先を指す項目は、空のファイルを作る代わりにスキップされます

下のバージョンは、アウトラインを切れ目の供給源として扱います。各項目のページを解決し、どこも指さない項目と末尾の先を指す項目を落とし、共有ページでは最初のタイトルを残し、ページ番号でソートし、最初の切れ目がページ 1 より後なら「前付け」セクションを足します。前付けは、チャプター 1 に貼り付ける代わりに、ここでは自分のファイルを得ます。チャプター 1 を受け取る人は、目次ではなく、ブックマークが名指したページで開くことを期待しているからです

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  Starts: array of Integer;
  Titles: array of string;
  I, J, K, N, P, EndPage: Integer;
  Known: Boolean;
  PdfOut: TPdf;
  OutFile: string;
begin
  Bm := Source.Bookmarks;                // トップレベルの項目だけ
  if Length(Bm) = 0 then
    Exit;                                // 切る場所がない
  SetLength(Starts, Length(Bm) + 1);     // +1 は前付けの余地
  SetLength(Titles, Length(Bm) + 1);
  N := 0;
  for I := 0 to High(Bm) do
  begin
    P := Bm[I].PageNumber;               // /Dest から解決
    if P < 1 then
      P := Bm[I].ActionPageNumber;       // /A の GoTo アクションから解決
    if (P < 1) or (P > Source.PageCount) then
      Continue;                          // 未解決、または最後のページの先
    Known := False;
    for J := 0 to N - 1 do
      if Starts[J] = P then
        Known := True;                   // ページで最初のタイトルが勝つ
    if Known then
      Continue;
    J := N;                              // ページ番号での挿入ソート
    while (J > 0) and (Starts[J - 1] > P) do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
      Dec(J);
    end;
    Starts[J] := P;
    Titles[J] := Bm[I].Title;
    Inc(N);
  end;
  if N = 0 then
    Exit;
  if Starts[0] > 1 then                  // 表紙、目次、その前のものすべて
  begin
    for J := N downto 1 do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
    end;
    Starts[0] := 1;
    Titles[0] := 'Front matter';
    Inc(N);
  end;

  PdfOut := TPdf.Create(nil);
  try
    for K := 0 to N - 1 do
    begin
      if K < N - 1 then
        EndPage := Starts[K + 1] - 1
      else
        EndPage := Source.PageCount;
      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source,
          Format('%d-%d', [Starts[K], EndPage]), 1) then
          raise Exception.CreateFmt('Cannot import pages %d-%d',
            [Starts[K], EndPage]);
        // このルーチンの前に SafeFileName(下で示す)を宣言しておく
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
    end;
  finally
    PdfOut.Free;
  end;
end;

ソート後の開始位置は厳密に増加するので、計算されたスパンはすべて有効で、読めるソースでは ImportPages のガードは発火しないはずです。ブックマークのない文書は、このモードでは単純にファイルを生みません。チャンク分割へフォールバックするかどうかは、呼び出し側が決めます。1 段深く切るとき、たとえば部の中の章でなら、BookmarkChildren[Bm[I]] が、トップレベル項目の子を同じ TBookmarks 形で返し、同じ切れ目のロジックが結合リストに当てはまります

各出力ファイルが運ぶもの

各出力ファイルは、ページそのものと、ページ辞書が参照するものすべてを運びます。コンテンツストリーム、フォント、画像、各ページの注釈リストです。フォームウィジェットは注釈として付いてきますが、新しいファイルには AcroForm がないので、もはや動くフィールドではありません。ソースは読まれるだけです。ImportPages はオブジェクトを新しい文書へコピーし、開いたままのソースには手を付けないので、同じ Source インスタンスが毎反復に仕えます

アウトラインは後ろに置き去りになります。ブックマークは、どのページの外側、文書カタログの /Outlines の下に住んでいます(ISO 32000-1 §7.7.2)。だからインポートされたページから作られたファイルは、ブックマークなしで始まります。チャプターファイルなら、それで十分なことが多いです。各部分に自分のナビゲーションが要るとき、アウトラインは新しいページ番号のために組み立て直す必要があり、それが PDF アウトラインの編集とページの再マッピングの話題です。内部リンクも同じ道をたどります。ページ 12 の「第 7 章を参照」リンクは、別のファイルへ行ったページを指し、このファイルでは着地する場所がありません

暗号化されたソースには、ループの始まる前にパスワードが要ります。コンポーネントが非アクティブの間に Source.Password を代入し、それから Active := True にします。パスワードが違うとコンポーネントは非アクティブのまま、SplitIntoPages の検査がそれをエラーに変えます。SaveAs が書く出力は、暗号化辞書のない新しい文書です。受け取り手の違う分割ファイルには都合がよく、各部分が再び保護される必要があるときは、SaveAsEncrypted(FileName, Options) が、自分のユーザーとオーナーのパスワードを持つ TPdfEncryptOptions レコードを受け取ります

ファイル名、失敗した保存、やり直し

ブックマークのタイトルは PDF テキスト文字列で、何でも含められます。Windows がファイル名で拒む文字もです。規則は PDF ではなくファイルシステムから来ます。< > : " / \ | ? * と制御文字は無効で、末尾の空白とドットは Win32 ファイル API に剥ぎ取られ、CON、PRN、COM1 のような名前は予約デバイスです。SplitByBookmarks の番号接頭辞は、すでにデバイス名の問題を片付け、ディレクトリ一覧でファイルを読み順に保ち、「Summary」というタイトルの 2 つの章が互いに上書きするのを防ぎます。残りは、自分で書く小さな関数です

// リーダー側のヘルパー。PDFium Component の一部ではない
function SafeFileName(const Title: string): string;
const
  Forbidden = '<>:"/\|?*';
var
  I: Integer;
begin
  Result := Title;
  if Length(Result) > 80 then
    SetLength(Result, 80);               // フルパスを MAX_PATH の十分下に保つ
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
      Result[I] := '_';
  while (Result <> '') and
    ((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
    SetLength(Result, Length(Result) - 1);
  if Result = '' then
    Result := 'section';
end;

ファイル名付きの SaveAs は、ターゲットフォルダの一時ファイルへ書き、保存が成功した後にだけ所定の場所へ入れ替えます。ターゲットは、したがって、前のファイルか完全な新しいファイルかのどちらかで、書きかけになることはありません。だから既存の出力フォルダの上で分割をやり直すのは無害です。失敗は 2 つの形で戻ります。文書をシリアライズできないとき、SaveAs は False を返し、一時ファイルは取り除かれます。存在しない出力フォルダ、OS が拒むパス、ビューアにロックされたターゲットのようなファイルシステムの問題は、ファイル層から例外を上げます

バッチがそうした失敗にどう反応するかは選択で、分割ではマージと違います。部品の欠けたマージ束は間違った文書なので、そこでは止まるのが正しいです。分割の出力は互いに独立です。section_147.pdf が、同僚が開いているせいで書けなくても、他のセクションはまだ良好です。分割の仕事では、ループの内側でファイルごとの例外を catch し、ファイル名とメッセージを記録し、続け、ループの終わりに、書かれたファイル数を計画された範囲数と比較してください。上の例が最初の失敗で raise しているのは、短く保つためだけです。200 個のうち 199 個が書かれ、ロックされていた 1 個の名前を告げるログなら、やり直し 1 回で直ります。ファイル 147 で中断した実行は、どの 53 個が欠けているか、誰かが考え込むことになります

ここで使った分割ループ、ブックマークレコード、暗号化保存はすべて、Delphi と C++Builder 向けの PDFium ベース PDF ライブラリ、PDFium Component に付いてきます