コンテンツにスキップ
getnextpdf.com

PDF を分割してページ範囲を抽出する

1 つの PDF があり、複数が必要だとします。このレシピでは、Core の分割用 API である NextPDF\Document\PdfSplitter を使って、単一のドキュメントを複数のファイルへ切り分けます。ソースは生の PDF バイト文字列として渡し、必要なページを記述します。 スプリッターはソースをオブジェクトグラフを通じて解析し、要求された各範囲の到達可能なオブジェクトを、独自のページツリーとクロスリファレンステーブルを持つ新しい再採番済みドキュメントへコピーし、適合リーダーで読み込める構造的に完全な PDF を返します。

これは マージレシピ の逆です。マージは多くのドキュメントを 1 つに構成し、分割は 1 つのドキュメントを多くに分解します。 同じ API が、もっともよく必要となる 3 つのタスクを扱います。

  • 範囲で分割――指定した各ページ範囲ごとに 1 つの出力ドキュメントを生成します。
  • N ページごとに分割――長いファイルを固定サイズのセグメントに切り分けます。
  • 範囲を抽出――単一の連続したページ範囲を 1 つのドキュメントへ取り出します。

分割はプロセス内で実行され、ヘッドレスブラウザもネットワーク呼び出しも使用しません。 Core のインストール(composer require nextpdf/core:^3)と、読み取り可能な 1 つの PDF が必要です。

Terminal window
composer require nextpdf/core:^3

PDF は /Pages ノードをルートとするページツリーを通じてページを探し、そのクロスリファレンスデータ(テーブルまたはストリーム)を通じてすべての間接オブジェクトに到達します。バイトを切り出してページを抽出することはできません。単一のページは、 ファイル内の別の場所にある共有フォント、画像、リソース辞書を参照しており、 クロスリファレンスのオフセットはもはや有効ではなくなるからです。

PdfSplitter が実際の作業を行います。各範囲について、要求されたページオブジェクトからオブジェクトグラフをたどり、到達可能なオブジェクトの閉包を集め、それらのオブジェクトを新しいアドレス空間へ再採番し、単一ページツリーのドキュメントを再構築して、PDF 2.0 構造に従った実際のクロスリファレンステーブルを出力します(ISO 32000-2:2020、 クロスリファレンステーブル §7.5.4、ページツリー §7.7.3)。各出力は断片ではなく、 自己完結したドキュメントです。

ページ番号は 1 始まりかつ両端を含みます。範囲は NextPDF\Document\PageRange の値オブジェクトです。new PageRange(2, 5) はページ 2 から 5 までを意味します。 コンストラクタは自身の不変条件を検証します――開始が 1 未満、または終了が開始より前の場合は NextPDF\Exception\PageLayoutException を送出して拒否します――そのため、 不可能な範囲はスプリッターの奥深くではなく、構築時に失敗します。PageRange::parse()PageRange::all() も、不正な指定や非正のページ総数に対して同じ PageLayoutException を送出します。

new NextPDF\Document\PdfSplitter() は 3 つのメソッドを公開します。いずれもソースを生の PDF バイト文字列として受け取り、パスは受け取りません。

  • split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResult は、$ranges 内の各 PageRange ごとに 1 つの出力ドキュメントを順番に生成します。 2 つの境界パラメータが、入力サイズと範囲数を上限で抑えます。
  • splitEvery(string $pdfData, int $pagesPerSegment): SplitResult は、ドキュメントをそれぞれ $pagesPerSegment ページの固定サイズセグメントへ切り分けます。最後のセグメントが残りを保持します。
  • extractPages(string $pdfData, PageRange $range): SplitDocument は、単一の範囲を抽出し、その 1 つのドキュメントを直接返します。

split()splitEvery()NextPDF\Document\SplitResult を返します。これは readonly オブジェクトで、$documents(セグメントのリスト)、$totalPages (ソース内のページ数)、$sourceSize を保持します。count()、ゼロ始まりのインデックスでセグメントを取得する document(int $index)、そして totalOutputSize() を提供します。

各セグメント、および extractPages() の戻り値は NextPDF\Document\SplitDocument です。これは readonly オブジェクトで、$pdfData(セグメントのバイト列)、 $range$pageCount$sizeBytes、そして isValid() ヘルパーを公開します。 isValid() は狭い %PDF ヘッダーの健全性チェックです――セグメントのバイト列が %PDF で始まるときに true を返します――ドキュメント構造や適合性の検証ではありません。スプリッターが PDF を生成したことを確認するものであり、ファイルが完全に適合していることを確認するものではありません。

PageRangenew PageRange($start, $end) で直接構築するか、人間が読める指定を PageRange::parse('1-3,5,7-10') で解析します。後者は split() にそのまま渡せる list<PageRange> を返します。PageRange::all($totalPages) は、ドキュメント全体をカバーする単一の範囲を返します。

このサンプルは 1 つのファイルを読み込み、2 つのドキュメント――ページ 1 から 3 と、 ページ 4 から 6――に分割します。呼び出しの形を示すためエラー処理は省いています。 下の本番サンプルが完全なガードを追加します。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Document\PageRange;
use NextPDF\Document\PdfSplitter;
$splitter = new PdfSplitter();
$result = $splitter->split(
file_get_contents(__DIR__ . '/report.pdf'),
[
new PageRange(1, 3),
new PageRange(4, 6),
],
);
foreach ($result->documents as $i => $segment) {
file_put_contents(__DIR__ . sprintf('/part-%d.pdf', $i + 1), $segment->pdfData);
}
printf("Split %d-page source into %d document(s).\n", $result->totalPages, $result->count());

この自己完結したプログラムは、メモリ内に 1 つの小さな複数ページのドキュメントを構築するため、外部ファイルなしで実行できます。3 つの操作すべて――範囲で分割、 N ページごとに分割、単一範囲を抽出――を示します。範囲ごとのセグメントと抽出した末尾を検証して書き出し、サイズごとの結果は件数として報告します。これにより、 ほぼ同一の 3 つの書き込みループなしで各呼び出しの形がわかります。分割 API が送出する例外をキャッチし、握りつぶす代わりにコンテキストを付けて再送出します。 メモリ内のソースを、あなた自身の file_get_contents() の読み込みやオブジェクトストレージの取得に置き換えてください。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use InvalidArgumentException;
use NextPDF\Core\Document;
use NextPDF\Document\Merge\UnsupportedSourceDocumentException;
use NextPDF\Document\PageRange;
use NextPDF\Document\PdfSplitter;
use NextPDF\Document\SplitDocument;
use NextPDF\Exception\PageLayoutException;
/**
* Build a tiny labelled multi-page PDF so the program is self-contained.
*
* In your own code, replace this with a read of the PDF you want to split,
* for example file_get_contents($path).
*/
function buildSample(int $pages): string
{
$doc = Document::createStandalone();
$doc->setTitle('Split sample');
for ($page = 1; $page <= $pages; $page++) {
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 10, sprintf('Source page %d', $page), newLine: true);
}
return $doc->getPdfData();
}
$source = buildSample(7);
$splitter = new PdfSplitter();
try {
// 1. Split into named ranges: one output per PageRange, in order.
$byRange = $splitter->split(
$source,
PageRange::parse('1-3,4-6'),
maxBytes: 50_000_000,
maxRanges: 100,
);
// 2. Split every 2 pages: segments of [1-2], [3-4], [5-6], [7] (remainder).
$bySize = $splitter->splitEvery($source, 2);
// 3. Extract a single range as one document.
$tail = $splitter->extractPages($source, new PageRange(7, 7));
} catch (InvalidArgumentException $e) {
// Raised on an oversized input, an empty range list, or too many ranges.
throw new RuntimeException('Split rejected its input: ' . $e->getMessage(), previous: $e);
} catch (PageLayoutException $e) {
// Raised when a range exceeds the source page count, and also by the
// PageRange constructor / PageRange::parse() on an invalid or malformed range.
throw new RuntimeException(
sprintf('Range out of bounds (page %d): %s', $e->getPageNumber(), $e->getConstraint()),
previous: $e,
);
} catch (UnsupportedSourceDocumentException $e) {
// Raised fail-closed on an encrypted, signed, or form-bearing source.
throw new RuntimeException('Source cannot be split: ' . $e->getMessage(), previous: $e);
}
printf(
"Source has %d page(s). By-range produced %d doc(s); by-size produced %d doc(s).\n",
$byRange->totalPages,
$byRange->count(),
$bySize->count(),
);
foreach ($byRange->documents as $i => $segment) {
emitSegment(sprintf('range-%d', $i + 1), $segment);
}
emitSegment('tail', $tail);
/**
* Validate a segment and write it to the cookbook side-channel directory,
* or to the script directory by default.
*/
function emitSegment(string $name, SplitDocument $segment): void
{
if (!$segment->isValid()) {
throw new RuntimeException(sprintf('Segment "%s" failed its %%PDF header check.', $name));
}
$dir = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$dir = $dir !== false && $dir !== '' ? $dir : __DIR__;
$path = sprintf('%s/%s.pdf', rtrim($dir, '/'), $name);
if (file_put_contents($path, $segment->pdfData) === false) {
throw new RuntimeException(sprintf('Could not write segment to "%s".', $path));
}
printf("Wrote %s: pages %d-%d, %d bytes.\n", $name, $segment->range->start, $segment->range->end, $segment->sizeBytes);
}

期待される標準出力(バイトサイズはビルドに依存します)。

Source has 7 page(s). By-range produced 2 doc(s); by-size produced 4 doc(s).
Wrote range-1: pages 1-3, <n> bytes.
Wrote range-2: pages 4-6, <n> bytes.
Wrote tail: pages 7-7, <n> bytes.
  • ソースはパスではなくバイト列です。 すべてのメソッドは生の PDF 文字列を受け取ります。まず file_get_contents() でファイルを読み込むか、オブジェクトストレージからバイトを取得してください。パスを渡すと、ソースの解析に失敗します。
  • ページ番号は 1 始まりかつ両端を含みます。 new PageRange(1, 3) はページ 1、2、3 ――3 ページ――をカバーします。開始が 1 未満、または終了が開始より前の場合、PageRange コンストラクタ自身が PageLayoutException を送出します。
  • 末尾を超える範囲は、クランプではなくエラーです。 範囲の終了がソースのページ数を超える場合、split()PageLayoutException を送出します。範囲を最後のページへ静かに切り詰めることは決してありません。範囲が呼び出し元から提供される場合は、まずページ数を確認してください。
  • splitEvery() は残りを保持します。 最後のセグメントは余ったページをすべて保持するため、7 ページのドキュメントを 2 ページごとに分割すると 4 つのセグメントになります。2 ページが 3 つと、1 ページが 1 つです。 $pagesPerSegment は少なくとも 1 でなければならず、そうでなければ InvalidArgumentException が発生します。
  • 空の範囲リストは拒否されます。 $ranges === [] を伴う split()InvalidArgumentException を送出します。呼び出す前に少なくとも 1 つの範囲を構築してください。
  • 境界は切り詰めずに送出します。 maxBytesmaxRanges を超えると InvalidArgumentException が発生します。スプリッターは過大な入力を部分的に処理することは決してないため、ワークロードに合わせて両方の境界を調整してください。
  • 暗号化、署名済み、フォームを含むソースはフェイルクローズします。 暗号化されたソース(鍵なしではコピーできない)、デジタル署名されたソース(再ページ付けは署名のバイト範囲を無効化する)、またはインタラクティブフォームを含むソース(フィールドのウィジェットが削除されたページ上に存在して孤立しうる)は、 UnsupportedSourceDocumentException を送出します。スプリッターは、破損した、 または改ざんされたドキュメントを出力するのではなく拒否します。フォームドキュメントの分割は、このリリースの既知の制限です。
  • UnsupportedSourceDocumentExceptionMerge 名前空間の下にあります。 完全修飾名は NextPDF\Document\Merge\UnsupportedSourceDocumentException です。 分割ページ上のその Merge パスはコピー&ペーストの誤りではありません。ソースを安全にコピーできないときにマージ面と分割面の両方が送出する、単一の共有されたソースドキュメント拒否例外です。その名前空間からインポートしてください。
  • 出力は構造的に新しく、バイト安定ではありません。 各セグメントは独自のカタログ、ページツリー、トレーラーを持つ新しいドキュメントです。同じ入力に対する 2 回の実行は構造的に等しくなりますが、バイト単位で同一であることは保証されません ――そのため structural 再現性プロファイルです。

分割は、すべての範囲にわたってコピーされるページ数に対して線形です。スプリッター自身の管理処理ではなく、ソースの解析と各範囲のオブジェクト閉包のコピーが作業の大半を占めます。ソースは文字列としてメモリ内に保持され、各セグメントのバイト列は書き出すまで保持されるため、ピークメモリはソースサイズと、生成する最大の範囲の合計を追います。maxBytes ガードは、そのピークのソース側を境界内に保ちます。 大量処理のパイプラインでは、不正または過大な入力がメモリを枯渇させる代わりに速やかに失敗するよう、maxBytesmaxRanges をワークロードが必要とする最小の値に設定してください。

分割はプロセス内で実行されます。ドキュメントのバイトがホストを離れることはなく、 ネットワーク呼び出しも行われません。すべてのソース PDF を信頼できない入力として扱ってください。

  • 境界をきつく保つ。 maxBytesmaxRanges は、サービス拒否を狙う入力に対する最初の防衛線です。アップロードを受け付けるあらゆる面では、寛大なデフォルトではなく実際の上限に設定してください。
  • 分割する前にトリアージする。 暗号化または署名されたソースはフェイルクローズしますが、それらの状態をより早く検出できます。信頼できない入力は、まず Core のインスペクターに通してください。暗号化、署名、リスクマーカーをより重い処理の前にフラグ付けする境界付きスキャンについては、PDF を解析して検査する を参照してください。
  • ユーザー入力をパスへ決して補間しない。 このレシピは固定ディレクトリまたはクックブックのサイドチャネルに書き込みます。パストラバーサルを避けるため、出力パスとセグメント名は、リクエストフィールドからではなく、サーバーが制御する値から導出してください。
  • 出力にシークレットを含めない。 見るべきでないクライアントに内部識別子をさらす場所、または名前で、セグメントファイルを書き出さないでください。

このレシピは、それ自体としては規範的な標準への適合を主張しません。Core の分割 API を通じて 1 つのドキュメントを分解し、各セグメントを SplitDocument::isValid()%PDF ヘッダーチェックで健全性確認します――これはスプリッターが PDF を出力したという存在チェックであり、適合性やドキュメント構造の検証ではありません。 PdfSplitter が各セグメントについて再構築するページツリーとクロスリファレンス構造は、/modules/core/document/ リファレンスに記述された PDF 2.0 構造です (ISO 32000-2:2020、クロスリファレンステーブル §7.5.4、ページツリー §7.7.3)。 バージョン、ページ数、暗号化、署名フラグを含む、任意の入力または出力ドキュメントの構造的な読み取りには、PDF を解析して検査する に文書化された Core インスペクターを使用してください。