Artikel Teknis

Penggabungan PDF Cepat di Delphi: Pergeseran Referensi di Level Byte

Concatenating PDFs sounds like it should be cheap. The page content is already laid out, the fonts are already embedded, the images are already compressed. In principle a merge is just bookkeeping: renumber the objects so two files' numbering spaces stop colliding, stitch the page trees together, fix up the cross-reference table, and write. In practice most merge code throws that cheapness away. For every object in every input file it runs a full parse into a tokenized object tree, mutates a couple of indirect references, then serializes the tree back to bytes. The parse and the reserialize are the expensive halves, and for the vast majority of objects they produce a byte sequence almost identical to what went in

PDFlibPas is a native Object Pascal PDF engine for Delphi and C++Builder, and its fast merge path exists to skip that round trip wherever it is provably safe. The idea is narrow but it pays off across whole document sets: for an unmodified non-stream object, take the original source bytes verbatim and do a single byte-level rewrite of the indirect references they contain, turning every N G R into (N+Offset) G R. No tokenizer, no object tree, no serializer. This article walks through where that shortcut is legal, the parser state machine that performs the byte rewrite without corrupting anything, why bookmark merging needed a different mechanism entirely, and how the ordinary merge path was rebuilt from quadratic to linear at the same time

Mengapa penomoran ulang objek menjadi biaya sebenarnya dari penggabungan

Every PDF carries its own object numbering space. File A has object 1, object 2, and so on; file B has its own object 1, object 2, and so on. You cannot drop B's objects into A's file unchanged, because the numbers would collide and every indirect reference inside B would now resolve to the wrong object. The fix is an offset: if A ends at object count Offset, then B's object N becomes object N+Offset in the output, and every reference N G R that appears anywhere inside B's objects must be shifted to (N+Offset) G R to match

That shift is the entire semantic job of merging the body. The page tree fixups and the AcroForm merge are small, bounded edits on a handful of objects. The bulk work is rewriting references across thousands of objects, and the naive way to do it is to parse each object so you can find the references structurally. PDFlibPas's MergeFileListFast takes the opposite view: the references are findable in the raw bytes too, if you are careful about the contexts where a digit-space-digit-space-R sequence is not a reference. Skip the parse, shift in place, and the per-object cost collapses to a single linear scan of bytes you were going to copy anyway

Kapan penggunaan ulang byte sumber terbukti aman

The byte path is only taken when three conditions all hold for the object being copied out of a following document. Any one of them failing sends the object back through the full decode-and-reserialize route, so correctness always wins over speed:

  • Doc2.IsChangedObject(X) is False. If the merge engine already mutated the object in memory (a page object whose /Parent was repointed, for instance), the in-memory tree is the source of truth and the original bytes are stale. Only untouched objects qualify
  • The source bytes contain no stream keyword. A stream object's body is opaque binary framed by stream/endstream, and a naive reference scan over compressed or encrypted stream data would happily "find" and corrupt byte patterns that look like references. Stream objects keep the original stream-aware path
  • The source bytes contain neither /StructTreeRoot nor /StructElem. In the fast profile the tagged-PDF structure tree is dropped rather than merged, so those objects must go through the decode path where the engine can null them out deliberately

The decision lives in the per-object copy loop. When all three checks pass, the object's bytes go straight to ShiftIndRefsInSource and then to the writer; otherwise the bytes are discarded and the object is rebuilt with GetObject, shifted with ShiftIndRef, and serialized. The structure of that branch is worth seeing, because the order of the checks is what keeps it safe:

ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
  ObjectData := FastMergeObjectSource(Reader2, X);
  if (PLPos('stream', ObjectData) > 0) or
     ((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
     ((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
    ObjectData := ''                                  // fall back to decode
  else
    ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;

if ObjectData <> '' then
  Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
  Obj := Doc2.GetObject(X, TempStruct);              // full parse path
  // ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;

An empty ObjectData is the signal that the byte path declined the object. That single sentinel keeps the fast and slow routes from drifting apart: there is exactly one place that decides, and exactly one fallback

State machine pergeseran referensi dan kasus pinggirannya

Penulisan ulang referensi tak langsung di level byte itu tampak mudah tetapi gampang salah, karena R dan rangkaian digit muncul di mana-mana di objek PDF dalam konteks yang bukan referensi. ShiftIndRefsInSource adalah scanner kecil buatan tangan yang menyapu byte satu kali dan hanya menulis ulang sebuah angka ketika angka itu diikuti, dengan whitespace PDF di antara token, oleh angka lain lalu delimiter R. Jalan keluar termurah didahulukan: jika offset nol atau sumber kosong, byte dikembalikan apa adanya tanpa masuk ke scanner sama sekali

Ketepatan scanner bergantung pada kemampuan mengenali konteks ketika urutan yang mirip referensi harus dibiarkan sendirian. Ini adalah batas yang paling mudah terlewat, dan masing-masing ditangani secara eksplisit:

  • Literal string yang dibatasi oleh ( dan ) disalin apa adanya, dengan melacak kedalaman nesting dan menghormati escape backslash supaya tanda kurung yang di-escape tidak mengacaukan hitungan kedalaman. String seperti (see object 3 0 R for details) berisi pola referensi textbook yang sebenarnya hanya prosa, dan harus lolos byte-for-byte
  • String heksadesimal yang dibatasi oleh < dan > dilewatkan tanpa interpretasi. Byte 52 di dalam hex string adalah kode ASCII untuk R, dan scanner yang memperlakukan payload hex sebagai teks bisa menciptakan referensi bayangan. Pembuka << dari dictionary dideteksi lebih dulu supaya dictionary tidak disangka sebagai hex string
  • Objek name yang diawali / dikonsumsi utuh, dari slash sampai whitespace atau delimiter berikutnya. Tanpa ini, name seperti /R yang umum dipakai sebagai resource key bisa dibaca sebagai R milik sebuah referensi
  • Komentar yang diawali % berjalan sampai akhir baris dan dilewati sebagai teks opak
  • Tes angka-lalu-R sangat ketat. Sebuah referensi hanya dikenali sebagai N whitespace G whitespace R dengan R yang diakhiri whitespace, delimiter, atau akhir input. Jika nomor generasi hilang, atau R diikuti huruf, digit ditulis keluar tanpa perubahan. Inilah yang melindungi integer di /Length 1234 dan empat angka dalam MediaBox agar tidak naik tanpa sengaja

Inti dari tes ketat itu hampir persis seperti kalimat spesifikasinya menjelaskannya:

if (P <= N) and (Source[P] = 'R') and
   ((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
  Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);

if Obj1 >= 0 then
begin
  AppendStr(PLIntToStr(Obj1 + Offset));   // shifted object number
  AppendBytes(E1, P - E1);                 // original whitespace + generation
  AppendBytes(P, 1);                       // the 'R'
end;

Hanya nomor objek yang ditulis ulang; nomor generasi dan whitespace asli yang tepat di antara token disalin lewat apa adanya, jadi output byte-identical terhadap input kecuali satu integer yang memang harus berubah. Ketelitian itu adalah inti segalanya, karena itulah yang membuat pemakaian ulang byte sumber setara dengan full reserialize, bukan sekadar mendekatinya. Perilakunya ditutup oleh seperangkat unit test yang terfokus, mencakup referensi polos, referensi di dalam array, angka yang bukan referensi, literal string, hex string, dan nomor generasi non-nol dengan offset yang diterapkan

Mengapa bookmark tidak bisa memakai ulang AppendOutline

Menggabungkan bookmark dari beberapa dokumen ke dalam satu outline tree tampak seperti pekerjaan untuk helper AppendOutline yang sudah ada, karena helper itu memang sudah tahu cara menyambungkan bookmark level atas satu dokumen ke dokumen lain. Tetapi di sini itu alat yang salah, dan alasannya adalah ketidakcocokan lapisan yang halus. AppendOutline mencari bookmark level atas terakhir yang aktif dengan menelusuri reader atas byte file asli. Namun jalur merge cepat men-stage editnya di buffer objek baru lewat ChangeObject; reader tidak pernah melihat edit tersebut. Jika Anda merantai tiga dokumen atau lebih, setiap append akan mengarahkan ulang bookmark terakhir asli milik dokumen pertama ke dokumen terbaru, sehingga semua bookmark dokumen perantara terlepas dari rantai, hanya /Count kumulatif yang tetap benar, dan bug ini mudah terlewat sampai seseorang membuka panel bookmark

Jalur cepat menyelesaikannya dengan injeksi dua fase berbasis metadata yang tidak pernah menelusuri reader ulang. Pass pertama atas semua input mengumpulkan, per dokumen, objek root outline dan nomor generasi, nomor bookmark level atas pertama dan terakhir, serta /Count root. Dari ringkasan itu, kode menghitung nomor objek global dari setiap sambungan yang perlu dipalsukan, yaitu /Parent level atas tiap dokumen ke root bersama, /Prev bookmark pertama ke bookmark terakhir dokumen sebelumnya, dan /Next bookmark terakhir ke bookmark pertama dokumen berikutnya, semuanya menggunakan aritmetika nomor objek murni. Ada batasan urutan tulis di balik ini: objek dokumen pertama ditulis lebih dulu sebelum dokumen berikutnya bahkan dibuka, jadi semua edit outline dokumen pertama, yakni root /Count dan /Last, plus /Next bookmark terakhir lama, harus bisa diekspresikan sebagai aritmetika yang tidak membutuhkan dokumen berikutnya di tangan. Edit untuk tiap dokumen berikutnya diterapkan langsung setelah dibuka tetapi sebelum ditulis, sehingga semuanya lewat jalur change-object yang sama

Invarian penyelarasan offset yang menyatukannya

Baik pergeseran referensi maupun injeksi bookmark bergantung pada satu invarian aritmetika, dan inilah asumsi yang paling rapuh dalam seluruh desain. Referensi yang disuntikkan ke dokumen berikutnya ditulis sebagai nomor objek global target dikurangi Offset dokumen itu, sehingga ketika objek tersebut nanti digeser oleh ShiftIndRef(Offset), nilainya jatuh ke nomor global yang dituju. Dokumen pertama mendapat Offset = 0 dan memakai nomor global secara langsung. Agar pengurangan itu benar, urutan offset yang dipakai saat injeksi harus cocok dengan urutan offset yang dipakai saat objek akhirnya ditulis keluar

Itu memang cocok, karena sifat cara merge page dan form bekerja: AddPages, AddFields, dan AddFieldFonts hanya mengubah objek yang sudah ada milik dokumen pertama, mereka tidak pernah menambahkan objek baru. Jadi jumlah objek dokumen pertama tetap sama selama tahap page-merge, dan offset tiap dokumen berikutnya, yaitu jumlah dari semua objek dokumen sebelumnya, tetap stabil dari injeksi sampai write-out. Jika itu dipecah, misalnya ada tahap yang membuat objek baru di tengah merge, setiap referensi page dan bookmark di hilir akan meleset sebesar jumlah objek yang Anda tambahkan. Invarian ini tidak terlihat, tetapi ia menanggung beban utama

Tiga entry point di atas satu engine

Jalur cepat bukan cabang lain dari kode merge. Dalam pekerjaan yang sama, engine level byte difaktorkan menjadi satu routine internal, MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode), dan API publik menjadi pembungkus tipis yang memilih dua flag:

  • MergeFileListFast memanggil engine dengan preservation structure-tree dimatikan, jalur paling ramping, yang membuang tagged-PDF tree supaya jalur byte berlaku pada sebanyak mungkin objek
  • MergeFileList memanggilnya dengan preservation diaktifkan, sehingga structure tree tetap hidup dan hasilnya tetap menjadi tagged PDF yang masih bisa dipakai. Jalur biasa ini juga mewarisi penggabungan bookmark dan form untuk banyak dokumen
  • MergeFileListStrict menyalakan strict mode: pass metadata pertama berhenti pada input pertama yang tidak melaporkan merge bersih, jadi hanya dokumen yang sudah terkumpul sebelum file bermasalah yang ikut, bukannya melewati file buruk itu lalu lanjut

Menyatukan jalur-jalur itu juga memungkinkan merge biasa dibangun ulang dari loop berpasangan O(N²), merge file satu dan dua, merge hasilnya dengan tiga, dan seterusnya sambil memparse ulang akumulator yang terus membesar pada setiap langkah, menjadi satu pass linear yang membuka tiap input sekali saja. Dua entry point lama untuk dua file dan dua stream, MergeFiles dan MergeStreams, tidak tersentuh dan tetap tersedia untuk caller yang memang benar-benar menginginkan merge berpasangan

Satu catatan jujur tentang perilaku structure-tree, karena ini sempat menjatuhkan test suite. "Penghapusan" di jalur cepat tidak total: jalur itu menghapus referensi katalog dokumen pertama ke /StructTreeRoot, tetapi objek structure-tree itu sendiri masih tetap ditulis keluar sebagai orphan. Jadi byte output cepat masih mengandung string /StructTreeRoot, dan Anda tidak bisa membedakan output cepat dari output biasa hanya dengan mencari string itu. Perbedaan yang sebenarnya adalah apakah katalog masih mencapai structure tree, karena itulah yang menentukan apakah file tersebut masih merupakan tagged PDF yang dapat dinavigasi

Kapan memakai jalur yang mana

Jalur byte adalah optimisasi throughput untuk menyusun banyak dokumen ketika Anda tidak perlu structure tree tagged-PDF dipertahankan, misalnya bundling report, run statement, atau concatenation batch. Saat diukur pada merge berulang atas set input menengah hingga besar, pemakaian ulang byte memangkas kira-kira empat sampai tiga belas persen dari waktu wall-clock tergantung campuran objek, tanpa kegagalan baru pada input kecil atau cacat, karena objek apa pun yang tidak bisa dibuktikan aman oleh scanner akan jatuh kembali ke parse penuh. Jika Anda memang membutuhkan structure tree utuh untuk aksesibilitas, pakailah jalur merge tagged-PDF biasa yang mempertahankannya; dan jika Anda bekerja dengan satu file yang sangat besar, bukan banyak input, teknik salin-byte yang dijelaskan di artikel pendamping tentang penggabungan dan pemisahan PDF besar dengan akses file langsung menerapkan filosofi yang sama, yakni "salin byte, hindari pohon objek penuh", pada skala file

Rutin merge dan varian cepat serta strict-nya merupakan bagian dari PDFlibPas Delphi PDF Library, yang dokumentasinya memuat referensi lengkap untuk API file-list dan opsi merge yang dibahas di sini