コンテンツにスキップ

高度な PDF パーサー診断

Artisan のインポート経路は、Chrome が生成した Portable Document Format(PDF)ファイルを読み取り、1 ページを NextPDF ドキュメントに取り込みます。そのインポートが扱いにくい入力で失敗した場合は、PageImporter::import() の下層、つまりファイルをバイト単位で読み取るパーサークラスを確認する必要があります。

このガイドでは、NextPDF\Parser 名前空間にある低レベルのパーサー面を説明します。対象は PdfReaderPdfTokenizerCrossRefParserStreamDecoderResourceCollectorRevisionExtractor、および値オブジェクト PdfObjectRevisionXRefTable です。ここに示すシンボルはすべて nextpdf/artisan に存在します。このガイドは、理想化された面ではなく、実装どおりのパーサーを記述しています。

このガイドは、解説と手順書の両方として読んでください。各部品がどのように連携するかを説明したうえで、増分更新リビジョンを調べる手順を案内します。この層の上にあるインポート境界については、Artisan 開発者ガイドを参照してください。

パーサー面は、通常のインポート経路がすでに失敗しており、原因を特定する必要がある場合にのみ使用してください。典型的なきっかけは次のとおりです。

  • PageImporter::import()NextPDF\Artisan\Exception\PdfParseException をスローし、原因が相互参照テーブル、ストリームフィルター、ページツリーのいずれにあるかを知る必要がある場合。
  • Chrome のアップグレードによって出力形式が変わり(従来の相互参照テーブルが相互参照ストリームになる、またはその逆)、フィクスチャが一致しなくなった場合。
  • Chrome 以外で生成されたサードパーティ製 PDF を受け取り、パーサーがそもそも読み取れるかどうかを確認したい場合。
  • 増分更新されたドキュメントのフォレンジック解析を行っており、リビジョンごとのバイト範囲やオブジェクトの可視性が必要な場合。

通常のレンダラー統合を作成している場合、この面は必要ありません。このパーサーは内部の診断ツールであり、汎用の PDF ライブラリではありません。暗号化された PDF、線形化されたヒントテーブル、オブジェクトの再定義が競合する増分更新はサポートしません。

パーサーは、単一責任のクラスを少数組み合わせたものです。PdfReader がエントリーポイントであり、その他はこれが構築または呼び出す協調オブジェクトです。

クラス責務主なメソッド
PdfReaderファイル構造を読み取り、オブジェクトを resolve(解決)して、ページツリーをたどります。parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerISO 32000-2:2020 §7.2 に準拠した字句解析(名前、文字列、数値、辞書、配列、参照)。readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParser従来の相互参照テーブルと相互参照ストリームを解析します。parseXRefTable(), parseXRefStream()
StreamDecoderストリームのバイトを /Filter に従ってデコードします。decode()(静的)
ResourceCollectorResources ツリーを深くたどり、到達可能なすべての間接オブジェクトを収集します。traverse(), getCollected()
RevisionExtractor増分更新されたファイルをリビジョンごとのバイト範囲に分割します。extractRevision()(静的)、getRevisionBoundaries()(静的)
PdfObjectイミュータブルな解析済み間接オブジェクト(辞書と任意のストリーム)。get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableイミュータブルなリビジョンごとの相互参照スナップショット。getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

生の PDF バイトを使って \NextPDF\Parser\PdfReader を構築し、他のどのメソッドよりも先に parse() を呼び出します。parse()%PDF- ヘッダーを確認し、ファイル末尾で startxref を見つけ、/Prev リンクをたどって相互参照チェーンを走査します。

リーダーは parse() の後、3 つのメソッド群を公開します。

  • オブジェクトアクセス。 getObject(int $objNum)PdfObject を返し、Type 2 エントリ(オブジェクトストリーム内に格納されたオブジェクト)を透過的に解決します。getObjectNumbers() は、すべての非フリーなオブジェクト番号をソート済みの list<int> として返します。resolveRef(mixed $value) は単一の間接参照をたどります。直接値はそのまま通過します。
  • ページアクセス。 getPage(int $pageIndex) はカタログを解決し、/Pages をたどり、ゼロ始まりのインデックスにあるページを返します。getPageContentStream()getPageResources()getPageMediaBox() は、PageImporter が必要とする部分を抽出します。collectPageResources() は、ページの Resources と Contents から到達可能なすべてのオブジェクトを array<int, PdfObject> として返します。
  • リビジョンアクセス。 getRevisionCount() は増分リビジョンの数を返します(単一リビジョンのファイルは 1 を返します)。getRevisionXRef(int $index) は 1 つの RevisionXRefTable を返します(インデックス 0 が最新です)。getRevisions() は完全な list<RevisionXRefTable> を返します。

PdfTokenizer はバイトストリームを読み取ります。自分で構築することはまれです。PdfReaderCrossRefParser がそれぞれのインスタンスを所有します。ただし、不正なトークンで解析が失敗したときに調べるべき層です。診断で重要な動作は 2 つあります。

  • セキュリティ制限は構成ではなく定数です。 トークナイザーは、リテラル文字列のネスト、辞書と配列のネスト、キーワード長、配列要素数に上限を設けています。制限を超えると、メッセージに制限名を含めて PdfParseException をスローします。これらの制限のいずれかに引っかかる細工された入力は、設計どおりに機能している防御であり、パーサーのバグではありません。
  • readValue() はディスパッチャーです。 次のバイトを調べ、readName()readLiteralString()readHexString()readArray()readDictionary()、または number/reference リーダーに委譲します。間接参照 N G R は、配列の形 ['type' => 'ref', 'num' => N, 'gen' => G] として返されます。この形は、PdfObject::getRef()PdfReader::resolveRef() が認識するものです。

CrossRefParser は、Chrome が出力し得る両方の形式を解析します。

  • parseXRefTable() は、従来の xref テーブル(PDF 1.x スタイル)を読み取ります。サブセクションヘッダーに続いて固定幅 20 バイトのエントリがあり、その後に trailer 辞書が続きます。
  • parseXRefStream() は、相互参照ストリーム(PDF 2.0、ISO 32000-2:2020 §7.5.8)を読み取ります。これは /Type /XRef を持つ間接オブジェクトで、/W フィールド幅配列と、エントリのバイナリストリームを備えています。

どちらも同じ形を返します。array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null} です。PdfReader::parse() は、相互参照オフセットの先頭 4 バイトをのぞき見て、どちらを呼ぶかを決定します。xref ならテーブルパーサーを選択し、それ以外はストリームオブジェクトとして扱われます。どちらのパーサーも、偽造された個数を拒否するためにサブセクションあたり 100 万エントリの上限を強制します。これがないと、パーサーが過度に実行されるおそれがあります。

StreamDecoder::decode(string $data, string|array $filter) は静的であり、単一のフィルター、または連結されたフィルターのリストを適用します。Chrome の printToPDF が出力するフィルターのみを正確にサポートします。

  • FlateDecode(zlib、raw deflate フォールバック付き)
  • ASCIIHexDecode
  • ASCII85Decode

それ以外のフィルター名では、Unsupported stream filter メッセージとともに PdfParseException をスローします。デコーダーは、解凍爆弾のリスクを抑えるために展開後の出力を 16 MiB に制限します。出力が大きすぎる場合は、制限なく割り当てるのではなくスローします。PdfReader がストリームを読み取り、デコードがスローした場合は、生のストリームバイトにフォールバックするため、単一の不良フィルターが解析全体を中断させることはありません。

ResourceCollectorPdfReader を使って構築され、PdfReader::collectPageResources() を通じて呼び出されます。その traverse() メソッドは値を再帰的にたどり、すべての ['type' => 'ref'] 参照を getObject() 経由で解決し、解決された各オブジェクトを、オブジェクト番号をキーとする array<int, PdfObject> に一度だけ記録します。再帰の深さを制限し、解決できない参照は黙ってスキップします。そのため、単一の宙ぶらりんの参照は、致命的な失敗ではなく部分的なコレクションをもたらします。

作成後に署名、注釈付け、その他の編集が行われた PDF には、増分更新があります。各編集は新しい相互参照セクションとトレーラーを追加し、%%EOF マーカーで終わります。RevisionExtractor は、解析済みの PdfReader に対して、完全に静的メソッドだけで動作します。

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) は、要求されたリビジョンの %%EOF 境界で切り詰めたファイルを返します。リビジョン 0(最新)はファイル全体を返し、より大きいインデックスは順に古いスナップショットを返します。
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) は、各リビジョンが寄与したバイト範囲を記述する list<array{revision, startByte, endByte, sizeBytes}> を返します。

この分離は意図的なものです。古いリビジョンを抽出すると、その時点まで可視だったオブジェクトだけが公開され、後のリビジョンが以前のオブジェクトを再定義するハイブリッド相互参照攻撃を防ぎます。

この手順では、Chrome による生成後に編集された可能性がある PDF のリビジョン履歴を調べます。この例はプロダクション向けの形です。strict types を宣言し、完全な型ヒントを使用し、入力を検証し、最も具体的な例外をキャッチします。

  1. PDF のバイトをメモリに読み込み、リーダーを構築する前に空の入力を拒否します。
  2. まず \NextPDF\Parser\PdfReader を構築し、parse() を呼び出します。
  3. 次に getRevisionCount() を読み取ります。値が 1 の場合は、増分更新のない単一リビジョンのファイルを意味します。
  4. 各リビジョンについて、その RevisionXRefTable を読み取り、getActiveObjectCount()hasRootUpdate()getSize() を確認します。
  5. リビジョンごとのバイト範囲を RevisionExtractor::getRevisionBoundaries() で計算します。
  6. パーサーが送出する最も具体的な例外である PdfParseException をキャッチし、診断メッセージを表面化させます。
examples/inspect-revisions.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
use NextPDF\Parser\RevisionXRefTable;
/**
* Inspect the incremental-update history of a PDF file.
*
* @return list<array{revision: int, activeObjects: int, rootUpdate: bool, size: int, startByte: int, endByte: int, sizeBytes: int}>
*
* @throws PdfParseException If the file is not a readable PDF.
*/
function inspectRevisions(string $path): array
{
$pdfData = \file_get_contents($path);
if ($pdfData === false || $pdfData === '') {
throw new PdfParseException("Cannot read PDF bytes from path: {$path}");
}
$reader = new PdfReader($pdfData);
$reader->parse();
$boundaries = RevisionExtractor::getRevisionBoundaries($pdfData, $reader);
$report = [];
foreach ($reader->getRevisions() as $table) {
\assert($table instanceof RevisionXRefTable);
$index = $table->index;
$boundary = $boundaries[$index];
$report[] = [
'revision' => $index,
'activeObjects' => $table->getActiveObjectCount(),
'rootUpdate' => $table->hasRootUpdate(),
'size' => $table->getSize(),
'startByte' => $boundary['startByte'],
'endByte' => $boundary['endByte'],
'sizeBytes' => $boundary['sizeBytes'],
];
}
return $report;
}

リーダーは、最新(index0)から最も古いものへとリビジョンを並べます。古いスナップショットをスタンドアロンのバイト列として 1 つ抽出するには(たとえば、編集による変更を差分で確認するために)、エクストラクターを直接呼び出します。

examples/extract-revision.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
/**
* Extract one revision of a PDF as standalone bytes.
*
* @throws PdfParseException If the file is unreadable or the revision index is out of range.
*/
function extractRevision(string $pdfData, int $revision): string
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
$reader->parse();
// Throws PdfParseException with an "out of range" message for an invalid index.
return RevisionExtractor::extractRevision($pdfData, $reader, $revision);
}

パーサーの障害はすべて NextPDF\Artisan\Exception\PdfParseException として表面化します。メッセージが原因を特定します。以下の表を使って、メッセージの断片を、それを送出したステージへ対応付けてください。

メッセージの断片ステージ意味
missing %PDF- headerPdfReader::parse()バイトが PDF ではないか、入力が先頭で切り詰められています。
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()ファイル末尾が破損しているか、相互参照ポインターが範囲外です。
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()従来の相互参照テーブルが不正な形式です。
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()相互参照ストリームに必須の辞書エントリが欠けています。
exceeds limit of(xref またはオブジェクトストリームの個数)CrossRefParser / PdfReader偽造された個数がサービス拒否ガードに引っかかりました。
Unsupported stream filterStreamDecoder::decode()ストリームが、サポートされている FlateDecode / ASCIIHexDecode / ASCII85Decode の集合の外にあるフィルターを使用しています。
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoder圧縮データが無効であるか、展開後に 16 MiB の上限を超えます。
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizer細工された、または病的な構造がトークナイザーの制限に引っかかりました。
Page index ... not found / out of range in subtreePdfReader::getPage()要求されたページインデックスがページツリーに存在しません。
Revision index ... out of rangePdfReader / RevisionExtractorリビジョンインデックスが 0 から getRevisionCount() - 1 の範囲外です。

例外をキャッチしたら、メッセージとソースパスをログに記録し、その後で再スローするか、定義済みのエラーを返します。黙って破棄してはいけません。空の catch ブロックは、パーサーが生成した唯一の情報を隠してしまいます。

examples/parse-with-diagnostics.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use Psr\Log\LoggerInterface;
/**
* Parse a PDF, logging the precise parser-stage message on failure.
*
* @throws PdfParseException Rethrown after logging so the caller can decide policy.
*/
function parseWithDiagnostics(string $pdfData, LoggerInterface $logger): PdfReader
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
try {
$reader->parse();
} catch (PdfParseException $exception) {
$logger->error('PDF parse failed', [
'reason' => $exception->getMessage(),
'bytes' => \strlen($pdfData),
]);
throw $exception;
}
return $reader;
}
  • 必ず最初に parse() を呼び出します。 PdfReader のすべてのアクセサーは、相互参照チェーンが読み込まれていることを前提とします。getObject()getPage()parse() の前に呼び出しても、有用なものは何も返りません。
  • パーサーは読み取り専用かつ Chrome 形状とみなしてください。 Chrome の printToPDF が出力する PDF 構文のサブセットを対象とします。暗号化された PDF、線形化されたヒントテーブル、競合する増分更新は、設計上対象外です。これを汎用の PDF 修復ツールへ拡張しないでください。
  • セキュリティ制限はそのまま維持してください。 ネスト、キーワード長、配列サイズ、相互参照数、解凍の各上限は、敵対的な入力に対してリソース使用を抑えるために存在します。制限による PdfParseException は、細工されたファイルに対する正しい結果です。そうしたファイルを受け入れるために制限を引き上げると、攻撃対象領域が広がります。
  • ページ 0 をデフォルトにします。 getPage()PageImporter::import() は最初のページをデフォルトとします。ワークフローが意図的に必要とする場合にのみ、別のインデックスを選択してください。
  • リーダーを構築する前に入力を検証してください。 上の例のように、空または読み取り不能なバイトを早期に拒否すると、明確なアプリケーションレベルのエラーがパーサー例外より先に発生します。
  • PdfParseException をキャッチし、裸の \Exception は決してキャッチしないでください。 これはパーサーが送出する唯一の具体的な型です。これをキャッチすることで、無関係な障害が覆い隠されるのを防ぎます。
  • Artisan 開発者ガイド — パーサーの上にあるインポート境界。ChromeHtmlRendererPageImporter、アーキテクチャの層構造を含みます。
  • Artisan API リファレンス — パッケージの公開面に関する公表済みのメソッド表。
  • Artisan トラブルシューティング — レンダラーとインポートの障害に対する症状起点のガイダンス。
  • Chrome レンダラーのセットアップ — このパーサーが読み取る PDF を生成するレンダラーの構成。
  • ISO 32000-2:2020 §7.5(ファイル構造、相互参照、増分更新)および §7.2(字句規約)— トークナイザーと相互参照パーサーが実装する仕様。権威あるバイトレベルの形式については、公表済みの規格を参照してください。