Lewati ke konten

Diagnostik parser PDF tingkat lanjut

Jalur impor Artisan membaca berkas Portable Document Format (PDF) yang dihasilkan Chrome dan memasukkan satu halaman ke dalam dokumen NextPDF. Ketika masukan yang sulit membuat impor gagal, telusuri dari PageImporter::import() hingga ke kelas parser yang membaca berkas bita demi bita.

Panduan ini membahas antarmuka parser tingkat rendah di namespace NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor, serta objek nilai PdfObject dan RevisionXRefTable. Setiap simbol yang ditampilkan di sini tersedia dalam nextpdf/artisan. Panduan ini menjelaskan parser sebagaimana dibangun saat ini, bukan antarmuka yang ideal.

Gunakan panduan ini sebagai penjelasan sekaligus petunjuk praktis. Panduan ini menunjukkan bagaimana setiap bagian saling terhubung, lalu memandu Anda memeriksa revisi pembaruan inkremental. Untuk batas impor di atas lapisan ini, lihat panduan pengembang Artisan.

Gunakan antarmuka parser hanya ketika jalur impor normal gagal dan Anda perlu menemukan penyebabnya. Pemicu umum meliputi:

  • PageImporter::import() melempar NextPDF\Artisan\Exception\PdfParseException, dan Anda perlu mengetahui apakah penyebabnya tabel referensi silang, filter aliran, atau pohon halaman.
  • Pemutakhiran Chrome mengubah format keluaran, misalnya ketika tabel referensi silang tradisional berubah menjadi aliran referensi silang, atau sebaliknya, sehingga fixture Anda tidak lagi cocok.
  • Anda menerima PDF pihak ketiga yang tidak dihasilkan Chrome dan ingin memastikan apakah parser mampu membacanya sama sekali.
  • Anda menganalisis dokumen yang diperbarui secara inkremental dan memerlukan rentang bita per revisi atau visibilitas objek.

Jika Anda menulis integrasi renderer biasa, Anda tidak memerlukan antarmuka ini. Parser ini adalah alat diagnostik internal, bukan pustaka PDF serbaguna. Alat ini tidak mendukung PDF terenkripsi, tabel petunjuk terlinearisasi, atau pembaruan inkremental dengan pendefinisian ulang objek yang saling bertentangan.

Parser ini terdiri dari sekumpulan kecil kelas dengan tanggung jawab tunggal. PdfReader adalah titik masuknya. Kelas-kelas lainnya adalah kolaborator yang dibuat atau dipanggil olehnya.

KelasTanggung jawabMetode utama
PdfReaderMembaca struktur berkas, meresolusi objek, dan menelusuri pohon halaman.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerMenganalisis sintaks leksikal sesuai ISO 32000-2:2020 §7.2: nama, string, angka, kamus, larik, dan referensi.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserMengurai tabel referensi silang tradisional dan aliran referensi silang.parseXRefTable(), parseXRefStream()
StreamDecoderMendekode bita aliran berdasarkan /Filter.decode() (statis)
ResourceCollectorMenelusuri pohon Resources secara rekursif dan mengumpulkan setiap objek tak langsung yang dapat dijangkau.traverse(), getCollected()
RevisionExtractorMemotong berkas yang diperbarui secara inkremental menjadi rentang bita per revisi.extractRevision() (statis), getRevisionBoundaries() (statis)
PdfObjectObjek tak langsung terurai yang tak dapat diubah (kamus beserta aliran opsional).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableSnapshot referensi silang per revisi yang tak dapat diubah.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Bentuk \NextPDF\Parser\PdfReader dengan bita PDF mentah, lalu panggil parse() sebelum memanggil metode lainnya. parse() memeriksa header %PDF-, menemukan startxref di akhir berkas, dan menelusuri rantai referensi silang dengan mengikuti tautan /Prev.

Setelah parse(), pembaca menyediakan tiga kelompok metode:

  • Akses objek. getObject(int $objNum) mengembalikan sebuah PdfObject, yang secara otomatis meresolusi entri Type 2 (objek yang disimpan di dalam aliran objek). getObjectNumbers() mengembalikan list<int> terurut dari setiap nomor objek yang tidak bebas. resolveRef(mixed $value) mengikuti satu referensi tak langsung. Nilai langsung diteruskan tanpa perubahan.
  • Akses halaman. getPage(int $pageIndex) meresolusi katalog, menelusuri /Pages, dan mengembalikan halaman pada indeks berbasis-nol. getPageContentStream(), getPageResources(), dan getPageMediaBox() mengekstrak bagian-bagian yang dibutuhkan PageImporter. collectPageResources() mengembalikan array<int, PdfObject> untuk setiap objek yang dapat dijangkau dari Resources dan Contents halaman tersebut.
  • Akses revisi. getRevisionCount() mengembalikan jumlah revisi inkremental. Berkas dengan satu revisi mengembalikan 1. getRevisionXRef(int $index) mengembalikan satu RevisionXRefTable (indeks 0 adalah yang paling baru). getRevisions() mengembalikan list<RevisionXRefTable> lengkap.

PdfTokenizer membaca aliran bita. Anda jarang perlu membentuknya sendiri karena PdfReader dan CrossRefParser memiliki instance masing-masing. Periksa lapisan ini ketika penguraian gagal pada token yang cacat. Dua perilaku penting untuk diagnostik:

  • Batas keamanan adalah konstanta, bukan konfigurasi. Tokenizer membatasi penyarangan string literal, penyarangan kamus dan larik, panjang kata kunci, serta jumlah elemen larik. Ketika masukan melampaui suatu batas, tokenizer melempar PdfParseException dan menyebutkan batas tersebut dalam pesannya. Masukan yang dirancang khusus untuk memicu salah satu batas ini adalah pertahanan yang bekerja sesuai rancangan, bukan bug parser.
  • readValue() mengarahkan penguraian. Metode ini memeriksa bita berikutnya dan mendelegasikannya ke readName(), readLiteralString(), readHexString(), readArray(), readDictionary(), atau pembaca number/reference. Referensi tak langsung N G R dikembalikan sebagai bentuk larik ['type' => 'ref', 'num' => N, 'gen' => G]. PdfObject::getRef() dan PdfReader::resolveRef() mengenali bentuk ini.

CrossRefParser — resolusi referensi silang

Bagian berjudul “CrossRefParser — resolusi referensi silang”

CrossRefParser mengurai dua format yang dapat dihasilkan Chrome:

  • parseXRefTable() membaca tabel xref tradisional (gaya PDF 1.x): header subbagian, entri 20 bita dengan lebar tetap, lalu kamus trailer.
  • parseXRefStream() membaca aliran referensi silang (PDF 2.0, ISO 32000-2:2020 §7.5.8): objek tak langsung dengan /Type /XRef, larik lebar-bidang /W, dan aliran biner berisi entri.

Keduanya mengembalikan bentuk yang sama: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() menentukan parser mana yang dipanggil dengan mengintip empat bita pada offset referensi silang: xref memilih parser tabel, sedangkan nilai selain itu diperlakukan sebagai objek aliran. Kedua parser memberlakukan batas atas satu juta entri per subbagian untuk menolak hitungan palsu yang, jika tidak dibatasi, akan membuat parser bekerja berlebihan.

StreamDecoder::decode(string $data, string|array $filter) bersifat statis dan menerapkan satu filter atau daftar filter berantai. Metode ini hanya mendukung filter yang dihasilkan printToPDF Chrome:

  • FlateDecode (zlib, dengan fallback raw-deflate)
  • ASCIIHexDecode
  • ASCII85Decode

Nama filter lainnya melempar PdfParseException dengan Unsupported stream filter. Dekoder membatasi keluaran terdekompresi hingga 16 MiB untuk mengurangi risiko bom dekompresi. Keluaran yang terlalu besar melempar pengecualian alih-alih mengalokasikan memori tanpa batas. Ketika PdfReader membaca aliran dan proses dekode melempar pengecualian, pembaca kembali ke bita aliran mentah, sehingga satu filter yang buruk tidak menggagalkan seluruh penguraian.

ResourceCollector — penelusuran sumber daya mendalam

Bagian berjudul “ResourceCollector — penelusuran sumber daya mendalam”

ResourceCollector dibentuk dengan PdfReader dan dipanggil melalui PdfReader::collectPageResources(). Metode traverse() menelusuri sebuah nilai secara rekursif, mengikuti setiap referensi ['type' => 'ref'] melalui getObject(), dan mencatat setiap objek yang teresolusi satu kali dalam array<int, PdfObject> yang dikunci dengan nomor objek. Metode ini membatasi kedalaman rekursi dan diam-diam melewati referensi yang tidak dapat diresolusinya, sehingga satu referensi yang menggantung menghasilkan koleksi parsial alih-alih kegagalan total.

RevisionExtractor — pembaruan inkremental dan revisi

Bagian berjudul “RevisionExtractor — pembaruan inkremental dan revisi”

PDF yang ditandatangani, dianotasi, atau disunting dengan cara lain setelah dibuat akan membawa pembaruan inkremental. Setiap suntingan menambahkan bagian referensi silang dan trailer baru yang diakhiri dengan penanda %%EOF. RevisionExtractor bekerja sepenuhnya melalui metode statis pada PdfReader yang sudah diurai:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) mengembalikan berkas yang dipotong pada batas %%EOF dari revisi yang diminta. Revisi 0 (paling baru) mengembalikan seluruh berkas; indeks yang lebih tinggi mengembalikan snapshot yang lebih lama.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) mengembalikan list<array{revision, startByte, endByte, sizeBytes}> yang menjelaskan rentang bita yang disumbangkan setiap revisi.

Isolasi ini disengaja. Mengekstrak revisi yang lebih lama hanya memunculkan objek yang terlihat hingga titik tersebut, sehingga memblokir serangan referensi silang hibrida ketika revisi yang lebih baru mendefinisikan ulang objek yang lebih awal.

Panduan langkah demi langkah: memeriksa sebuah revisi

Bagian berjudul “Panduan langkah demi langkah: memeriksa sebuah revisi”

Prosedur ini memeriksa riwayat revisi sebuah PDF yang mungkin telah disunting setelah dihasilkan oleh Chrome. Contoh ini dirancang untuk produksi: mendeklarasikan tipe ketat, menggunakan petunjuk tipe lengkap, memvalidasi masukan, dan menangkap pengecualian yang paling spesifik.

  1. Baca bita PDF ke dalam memori dan tolak masukan kosong sebelum membentuk pembaca.
  2. Bentuk \NextPDF\Parser\PdfReader dan panggil parse().
  3. Baca getRevisionCount(). Nilai 1 berarti berkas memiliki satu revisi tanpa pembaruan inkremental.
  4. Untuk setiap revisi, baca RevisionXRefTable-nya dan periksa getActiveObjectCount(), hasRootUpdate(), dan getSize().
  5. Hitung rentang bita per revisi dengan RevisionExtractor::getRevisionBoundaries().
  6. Tangkap PdfParseException, pengecualian paling spesifik yang dilempar parser, dan tampilkan pesan diagnostik.
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;
}

Pembaca mengurutkan revisi dari yang terbaru (index0) hingga yang terlama. Untuk mengekstrak satu snapshot yang lebih lama sebagai bita mandiri, misalnya untuk membandingkan perubahan dari suatu suntingan, panggil ekstraktor secara langsung:

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);
}

Setiap kegagalan parser muncul sebagai NextPDF\Artisan\Exception\PdfParseException. Pesannya mengidentifikasi penyebab kegagalan. Gunakan tabel di bawah ini untuk memetakan fragmen pesan ke tahap yang memunculkannya.

Fragmen pesanTahapApa artinya
missing %PDF- headerPdfReader::parse()Bita tersebut bukan PDF, atau masukan terpotong di awal.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()Akhir berkas rusak, atau penunjuk referensi silang berada di luar batas.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Tabel referensi silang tradisional cacat.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()Aliran referensi silang kehilangan entri kamus yang diperlukan.
exceeds limit of (jumlah xref atau object-stream)CrossRefParser / PdfReaderHitungan palsu memicu pelindung denial-of-service.
Unsupported stream filterStreamDecoder::decode()Aliran menggunakan filter di luar set FlateDecode / ASCIIHexDecode / ASCII85Decode yang didukung.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderData terkompresi tidak valid atau membesar melampaui batas 16 MiB.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerStruktur yang dirancang khusus atau patologis memicu batas tokenizer.
Page index ... not found / out of range in subtreePdfReader::getPage()Indeks halaman yang diminta tidak ada di pohon halaman.
Revision index ... out of rangePdfReader / RevisionExtractorIndeks revisi berada di luar rentang 0 hingga getRevisionCount() - 1.

Ketika Anda menangkap pengecualian, catat pesan dan jalur sumbernya, lalu lempar ulang atau kembalikan kesalahan yang terdefinisi. Jangan abaikan secara diam-diam. Blok catch yang kosong menyembunyikan satu-satunya informasi yang sudah dihasilkan parser.

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;
}
  • Selalu panggil parse() terlebih dahulu. Setiap aksesor pada PdfReader mengasumsikan rantai referensi silang telah dimuat. Memanggil getObject() atau getPage() sebelum parse() tidak menghasilkan apa pun yang berguna.
  • Perlakukan parser sebagai hanya-baca dan berbentuk Chrome. Parser ini menargetkan bagian sintaks PDF yang dihasilkan printToPDF Chrome. PDF terenkripsi, tabel petunjuk terlinearisasi, dan pembaruan inkremental yang saling bertentangan berada di luar cakupan secara rancangan. Jangan kembangkan menjadi alat perbaikan PDF umum.
  • Pertahankan batas keamanan tetap berlaku. Batas penyarangan, panjang kata kunci, ukuran larik, jumlah referensi silang, dan dekompresi membatasi penggunaan sumber daya pada masukan yang berbahaya. PdfParseException akibat suatu batas adalah hasil yang benar untuk berkas yang dirancang khusus. Menaikkan batas untuk menerima berkas semacam itu memperluas permukaan serangan.
  • Gunakan halaman 0 sebagai standar. getPage() dan PageImporter::import() menggunakan halaman pertama sebagai standar. Pilih indeks lain hanya ketika alur kerja memang membutuhkannya.
  • Validasi masukan sebelum membentuk pembaca. Tolak bita kosong atau tak terbaca sejak dini, seperti yang dilakukan contoh di atas, sehingga kesalahan tingkat aplikasi yang jelas muncul sebelum pengecualian parser apa pun.
  • Tangkap PdfParseException, jangan pernah \Exception mentah. Itu adalah satu-satunya tipe spesifik yang dilempar parser. Menangkapnya mencegah kegagalan yang tidak terkait dari tertutupi.
  • Panduan pengembang Artisan — batas impor di atas parser, termasuk ChromeHtmlRenderer, PageImporter, dan lapisan arsitektur.
  • Referensi API Artisan — tabel metode terpublikasi untuk antarmuka publik paket.
  • Pemecahan masalah Artisan — panduan berbasis gejala untuk kegagalan renderer dan impor.
  • Penyiapan renderer Chrome — mengonfigurasi renderer yang menghasilkan PDF yang dibaca parser ini.
  • ISO 32000-2:2020 §7.5 (struktur berkas, referensi silang, pembaruan inkremental) dan §7.2 (konvensi leksikal) — spesifikasi yang diimplementasikan oleh tokenizer dan parser referensi silang. Rujuk standar yang dipublikasikan untuk format tingkat-bita yang otoritatif.