Artikel Teknis

Membangun Ulang Tabel Xref PDF Rusak: Recovery Scan Delphi

Ketika tabel cross-reference PDF tidak bisa dipakai, solusinya adalah mengabaikannya sepenuhnya dan membangunnya ulang dari isi berkas. PDFlibPas Delphi PDF Library melakukan ini dengan token scanner satu pass yang mencatat setiap header indirect object asli yang ditemuinya, lalu memulihkan trailer dictionary dan menyerahkan tabel hasil rekonstruksi ke loader normal

Apa yang pertama kali rusak saat PDF bermasalah

Tabel cross-reference adalah bagian PDF yang paling rapuh, karena inilah satu-satunya bagian yang menyimpan byte offset absolut. ISO 32000-1 §7.5.4 mendefinisikan entri-entri itu sebagai offset sepuluh digit dari awal berkas, dan §7.5.5 menempatkan keyword startxref mendekati akhir berkas, menunjuk ke tabel itu sendiri. Setiap angka tersebut menjadi tidak valid oleh perubahan apa pun yang menggeser byte. Sesi FTP yang berjalan dalam mode teks dan menerjemahkan CRLF, unduhan yang terpotong, sektor yang rusak pada drive bersama, tool batch yang menambahkan data tanpa menulis incremental update dengan benar: semuanya meninggalkan data objek yang tetap terbaca sempurna dan indeks yang menunjuk ke sampah

Itulah sebabnya dialog "berkas rusak dan sedang diperbaiki" begitu sering muncul. Byte-nya hampir selalu masih ada di sana. Yang hilang adalah petanya. Karena itu rekonstruksi bukanlah pemulihan forensik atas data yang hilang, melainkan pembangunan ulang indeks yang bisa diturunkan dari isi berkas, dan ini berhasil jauh lebih sering daripada dugaan pengguna karena konten yang mahal, page tree, font, dan gambar, tidak tersentuh

Kenapa memindai pola N 0 obj menemukan kecocokan palsu?

Rebuild yang naif mencari byte mentah untuk pola "integer, integer, obj" dan mencatat setiap kecocokan. Hasilnya terlalu banyak. PDF adalah format container, dan tiga region dalam sebuah berkas bersifat opak bagi tata bahasa objek: komentar (§7.2), string (§7.3.4), dan data stream (§7.3.8). Semua region itu bisa berisi byte yang terbaca persis seperti header objek, padahal tidak satu pun dari mereka benar-benar header objek. Caption dalam sebuah literal string, komentar debug yang tertinggal, atau dua megabyte output Flate atau DCT semuanya bisa dengan mudah menghasilkan sesuatu yang tampak seperti 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

Setiap entri palsu memakan biaya dua kali. Ia mencemari tabel hasil rebuild dengan object number yang sebenarnya tidak ada, dan ia bisa membayangi objek asli dengan nomor yang sama yang muncul belakangan dalam berkas. Karena itu PDFlibPas sama sekali tidak melakukan pattern-matching. Ia melakukan tokenisasi, yang berarti ia selalu tahu apakah byte di bawah kursor adalah kode atau payload, dan payload dilewati tanpa pernah diinterpretasikan

State machine satu pass atas blok 64 KiB

PDFlibPas memindai seluruh berkas persis satu kali, dalam blok 64 KiB, dengan state machine yang dibangun atas aturan token ISO 32000-1 §7.2 dan sintaks indirect object §7.3.10. Sebuah token berakhir pada white space atau pada salah satu karakter delimiter, dan header objek hanya dicatat ketika urutan lengkap berupa object number positif, generation number non-negatif, dan keyword obj telanjang telah terlihat. Offset yang dicatat adalah awal dari token object number, yaitu yang harus ditunjuk oleh sebuah entri cross-reference, bukan posisi keyword obj

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

Detail pentingnya adalah state token dan state string bertahan melewati batas blok. Header yang membentang melintasi garis byte ke-65536 tetap dikenali, karena token parsial, pasangan integer yang tertunda, dan flag in-string semuanya terbawa ke blok berikutnya. Buffer-nya tetap: 64 KiB untuk scan, 32 byte untuk token terpanjang yang mungkin relevan, dan satu-satunya array yang tumbuh seiring ukuran berkas adalah daftar object number, generation number, dan offset 64-bit, yang proporsional terhadap jumlah objek sesungguhnya, bukan terhadap ukuran berkas. Pada praktiknya, scan ini melakukan pembacaan sekuensial dan paling banyak dua seek eksplisit atas seluruh dokumen, yang membuatnya layak dipakai pada input berukuran ratusan megabyte yang dibahas di artikel tentang direct access merge dan split

Kenapa sebuah stream tidak bisa dipercaya berakhir di endstream?

Karena data stream adalah byte sembarang, dan byte sembarang bisa kebetulan mengeja endstream. Sebuah stream yang dimulai setelah keyword stream harus dilewati sebagai data opak sampai benar-benar berakhir, tetapi kemunculan pertama keyword penutup hanyalah sebuah kandidat. PDFlibPas menyelesaikan ini dengan mensyaratkan korroborasi: token endstream diterima sebagai akhir stream yang sesungguhnya hanya ketika token non-white-space berikutnya adalah endobj yang berdiri sendiri, urutan yang disyaratkan §7.3.8 di sekeliling sebuah stream object. Kecocokan kebetulan di dalam data terkompresi hampir tidak pernah punya kelanjutan seperti itu, sehingga scanner tetap berada di dalam stream dan terus berjalan. Dua aturan yang lebih kecil sama pentingnya. Keyword stream hanya masuk ke state stream ketika ia berupa keyword telanjang, sehingga name object seperti /stream dalam sebuah dictionary tidak pernah memicunya. Dan token obj atau trailer hanya dihormati ketika token itu tidak melampaui batas 32 byte dan tidak dimulai dengan solidus. Tanpa dua penjaga ini, sebuah resource dictionary dengan nama key yang salah sudah cukup untuk menggagalkan scan, yang persis merupakan kelas input adversarial yang dibahas dalam catatan tentang mengurai PDF tidak terpercaya dengan aman

Menemukan akhir sesungguhnya dari trailer dictionary

Memulihkan objek hanyalah separuh pekerjaan, karena loader masih membutuhkan trailer untuk menemukan /Root. PDFlibPas mengingat 64 posisi keyword trailer terakhir yang ditemukan selama scan dan memvalidasinya secara mundur, yang paling baru lebih dulu, sehingga trailer yang bisa dipakai dan paling baru yang menang, dan keyword nyasar yang tidak diikuti dictionary akan langsung gagal validasi dan jatuh ke kandidat sebelumnya. Setiap kandidat dibaca dengan batas 1 MiB, dan akhir dictionary ditemukan dengan melacak kedalaman << dan >> yang bersarang bersama escape literal string, hexadecimal string, dan komentar

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Pelacakan kedalaman ini bukan sekadar akademis. Trailer yang terpotong dan kehilangan /Encrypt mengubah dokumen terenkripsi yang tadinya bisa dipulihkan menjadi tidak bisa dibuka sama sekali, dan kehilangan /Info atau sub-dictionary kustom secara diam-diam membuang metadata yang mungkin diandalkan sistem di hilir. Jika berkas terenkripsi, trailer hasil pemulihan itulah yang memungkinkan jalur kredensial normal berjalan, dan semantik retry-nya sama seperti yang dijelaskan di artikel tentang pemuatan dokumen terenkripsi

Apa yang tidak bisa dikembalikan oleh rekonstruksi

Rekonstruksi adalah upaya terbaik, dan bersikap jujur soal batasannya adalah bagian dari merilisnya. Ada tiga kasus yang gagal total. Objek yang dipadatkan di dalam object stream (§7.5.7) tidak terlihat satu per satu oleh pemindaian byte, sehingga jika sebuah container tetap ada tapi cross-reference stream-nya (§7.5.8) tidak, objek-objek yang dikandungnya tidak terindeks oleh rebuild. Berkas yang isinya benar-benar rusak, bukan sekadar salah indeks, akan menghasilkan header yang isinya tidak lagi bisa diurai. Dan berkas tanpa keyword trailer yang bisa dipulihkan dan tanpa catalog yang bisa dibaca tidak memiliki apa pun untuk menambatkan document tree, berapa pun banyaknya header objek yang ditemukan

Object number duplikat adalah kasus tengah yang menarik. Berkas yang diperbarui secara incremental secara sah bisa memiliki beberapa generation dari object number yang sama, dan rantai cross-reference yang bertahan adalah satu-satunya catatan tentang mana yang berlaku saat ini. Rebuild tidak memiliki rantai itu, sehingga ia mencatat setiap header yang ditemuinya sesuai urutan dalam berkas dan meresolusinya berdasarkan object number sesudahnya. Biasanya revisi yang lebih baru yang menang, dan itu biasanya benar, tetapi dokumen yang diperbarui lalu sebagian di-rollback bisa kembali dengan hasil yang sedikit berbeda dari yang dideskripsikan xref aslinya. Berkas linearised membawa peringatan yang sama dari arah berbeda: layout halaman pertama dan hint table jadi tidak bermakna begitu indeks diregenerasi, sehingga berkas yang diperbaiki sebaiknya diperlakukan sebagai dokumen biasa yang non-linearised

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

Fallback ini otomatis: PDFlibPas menjalankan scan mentah setiap kali rantai cross-reference tidak bisa dibaca, dan juga ketika setiap entri in-use mengklaim offset nol, yang merupakan ciri khas tabel yang sudah ditulis tapi tidak pernah diisi. GetDocumentRepaired mengembalikan 1 ketika jalur itu yang berjalan, dan ini layak di-log, bukan diabaikan, karena dokumen yang dimuat lewat rekonstruksi sebaiknya disimpan ulang ke berkas yang bersih, bukan dibiarkan dalam pipeline seolah tidak terjadi apa-apa. Menyimpannya menulis tabel cross-reference yang baru dan konsisten, yang merupakan perbaikan termurah yang mungkin bagi setiap consumer di hilir

Jalur rekonstruksi, flag GetDocumentRepaired, dan streaming loader yang ditunjukkan di sini adalah bagian dari PDFlibPas Delphi PDF Library, berdampingan dengan API parsing, rendering, dan signing yang dibahas di tempat lain pada blog ini