Spajanje PDF-ova zvuči kao nešto što bi trebalo da bude jeftino. Sadržaj stranica je već složen, fontovi su već ugrađeni, slike su već kompresovane. U principu, merge je samo administracija: renumerišite objekte tako da se numerički prostori dva fajla ne sudare, spojite stabla stranica, ispravite cross-reference tabelu i upišite. U praksi, većina koda za merge tu jeftinoću baci kroz prozor. Za svaki objekat u svakom ulaznom fajlu radi se pun parse u tokenizovano stablo objekata, menja se nekoliko indirektnih referenci, pa se stablo ponovo serijalizuje u bajtove. Parse i reserialize su skupi delovi, a za ogromnu većinu objekata proizvedu niz bajtova koji je gotovo identičan onome što je ušlo
PDFlibPas je nativni Object Pascal PDF motor za Delphi i C++Builder, a njegova brza merge putanja postoji da preskoči taj krug gde god je dokazivo bezbedno. Ideja je uska, ali se isplati kroz cele skupove dokumenata: za neizmenjen ne-tok objekat, uzmite originalne izvorne bajtove doslovno i uradite jednu byte-level izmjenu indirektnih referenci koje sadrže, pretvarajući svaki N G R u (N+Offset) G R. Nema tokenizatora, nema stabla objekata, nema serializer-a. Ovaj članak prolazi kroz to gde je ta prečica legalna, kroz parser state mašinu koja radi byte rewrite bez kvarenja ičega, zašto je spajanje bookmark-ova zahtevalo potpuno drugačiji mehanizam i kako je obična merge putanja u isto vreme prebačena sa kvadratne na linearnu
Zašto je renumerisanje objekata stvarna cena merge-a
Svaki PDF nosi svoj sopstveni prostor numeracije objekata. Fajl A ima objekat 1, objekat 2 i tako dalje; fajl B ima svoj objekat 1, objekat 2 i tako dalje. Ne možete da ubacite B-ove objekte u A-ov fajl nepromenjene, jer bi se brojevi sudarili i svaka indirektna referenca unutar B-a bi sada pokazivala na pogrešan objekat. Rešenje je pomeraj: ako A završava na broju objekta Offset, onda B-ov objekat N postaje objekat N+Offset u izlazu, a svaka referenca N G R koja se pojavljuje bilo gde unutar B-ovih objekata mora biti pomerena u (N+Offset) G R da bi se poklopila
To pomeranje je ceo semantički posao merge-a tela dokumenta. Popravke stabla stranica i merge AcroForm-a su male, ograničene izmene na šačici objekata. Glavni posao je prepisivanje referenci kroz hiljade objekata, a naivan način da se to uradi jeste da se svaki objekat parsira kako biste referencu pronašli strukturno. PDFlibPas-ov MergeFileListFast ima suprotan pristup: reference se mogu pronaći i u sirovim bajtovima, ako ste pažljivi oko konteksta u kome se sekvenca cifra-razmak-cifra-razmak-R pojavljuje ne kao referenca. Preskočite parse, pomerajte na mestu i cena po objektu se svodi na jedno linearno skeniranje bajtova koje biste ionako kopirali
Kada je ponovna upotreba izvornog bajta dokazivo bezbedna
Byte putanja se koristi samo kada tri uslova važe za objekat koji se kopira iz narednog dokumenta. Ako bilo koji od njih padne, objekat se vraća kroz pun decode-and-reserialize put, tako da tačnost uvek pobedi brzinu:
Doc2.IsChangedObject(X)je False. Ako je merge motor već izmenio objekat u memoriji (na primer objekat strane čiji je/Parentpreusmeren), stablo u memoriji je izvor istine i originalni bajtovi su zastareli. Samo netaknuti objekti kvalifikuju se- Izvorni bajtovi ne sadrže
streamkeyword. Telo stream objekta je neprozirni binarni sadržaj uokviren sastream/endstream, a naivna pretraga referenci preko komprimovanih ili šifrovanih stream podataka bi radosno "pronašla" i pokvarila byte pattern-e koji liče na reference. Stream objekti ostaju na originalnoj stream-aware putanji - Izvorni bajtovi ne sadrže ni
/StructTreeRootniti/StructElem. U brzom profilu tagovano-PDF stablo strukture se odbacuje umesto da se spaja, pa ti objekti moraju kroz decode putanju gde motor može namerno da ih poništi
Odluka živi u petlji kopiranja po objektu. Kada sva tri uslova prođu, bajtovi objekta idu direktno u ShiftIndRefsInSource a zatim u pisac; inače se bajtovi odbacuju i objekat se ponovo gradi sa GetObject, pomeren sa ShiftIndRef, i serijalizovan. Struktura te grane vredi videti jer upravo redosled provera drži stvar bezbednom:
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;
Prazan ObjectData je signal da je byte putanja odbila objekat. Taj jedan sentinel drži brzi i spori put od razmicanja: postoji tačno jedno mesto koje odlučuje i tačno jedna rezervna varijanta
Mašina za pomeranje referenci i njene ivice
Byte rewrite indirektnih referenci je varljivo lako pogrešno uraditi, jer R i nizovi cifara svuda po PDF objektu pojavljuju se u kontekstima koji nisu reference. ShiftIndRefsInSource je mala ručno pisana skener rutina koja prolazi bajtove jednom i prepisuje broj samo kada je za njim, sa PDF whitespace između tokena, drugi broj a zatim R delimiter. Jeftini izlazi dolaze prvi: ako je offset nula ili je izvor prazan, bajtovi se vraćaju netaknuti bez ulaska u skener uopšte
Ispravnost skenera zavisi od prepoznavanja konteksta u kojima sekvenca nalik referenci mora da ostane netaknuta. To su granice koje je najlakše propustiti, i svaka od njih se obrađuje eksplicitno:
- Literal strings ograničeni sa
(i)se kopiraju verbatim, uz praćenje dubine ugnježđivanja i poštovanje backslash escape-a tako da izbegnuta zagrada ne poremeti brojanje dubine. String poput(see object 3 0 R for details)sadrži školski primer reference koja je zapravo samo proza, i mora preživeti bajt po bajt - Hexadecimal strings ograničeni sa
<i>prolaze bez interpretacije. Bajtovi52unutar hex stringa su ASCII kod zaR, a skener koji bi hex payload tretirao kao tekst mogao bi da proizvede fantomsku referencu. Otvarajući<<rečnika otkriva se prvo, tako da rečnik ne bude pogrešno shvaćen kao hex string - Name objects koji počinju sa
/se čitaju do kraja, od slasha do sledećeg razmaka ili delimiter-a. Bez ovoga, naziv poput/R(uobičajen ključ resursa) mogao bi da bude pročitan kaoRreference - Komentari uvedeni sa
%idu do kraja linije i preskaču se kao neprozirni tekst - Test broj-pa-R je strog. Referenca se prepoznaje samo kao
NwhitespaceGwhitespaceRsaRzavršenim whitespace-om, delimiter-om ili krajem ulaza. Ako nedostaje generacijski broj, ili jeRpraćen slovom, cifre se emituju neizmenjene. To je ono što štiti celobrojno u/Length 1234i četiri brojaMediaBoxod tihog inkrementiranja
Srce tog strogog testa gotovo tačno prati rečenicu iz specifikacije:
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;
Samo se broj objekta prepisuje; generacijski broj i tačan originalni whitespace između tokena kopiraju se dalje, tako da je izlaz bajt-po-bajt identičan ulazu osim za jedan ceo broj koji je morao da se promeni. Ta preciznost je cela poenta - upravo to čini ponovnu upotrebu izvornog bajta ekvivalentnom punom reserialize, a ne samo bliskom. Ponašanje je pokriveno fokusiranim skupom unit testova koji vežbaju gole reference, reference unutar nizova, brojeve koji nisu reference, literal strings, hex strings i nenulte generacijske brojeve sa primenjenim offsetom
Zašto bookmark-ovi nisu mogli da koriste AppendOutline
Spajanje bookmark-ova više dokumenata u jedno outline stablo izgleda kao posao za postojeći AppendOutline pomoćni program, koji već zna da nakači top-level bookmark-ove jednog dokumenta na drugi. To je pogrešan alat ovde, i razlog je suptilno neslaganje slojeva. AppendOutline pronalazi trenutni poslednji top-level bookmark obilazeći reader preko originalnih bajtova fajla. Ali brzi merge odlaže izmene u buffer novih objekata kroz ChangeObject; reader te izmene nikada ne vidi. Ako spojite tri ili više dokumenata, svako dodavanje preusmerava prvobitni poslednji bookmark prvog dokumenta ka najnovijem dokumentu, pa svi bookmark-ovi iz međudokumenata ispadaju iz lanca - samo kumulativni /Count ostaje ispravan, što bag čini lakim za propuštanje dok neko ne otvori panel bookmark-ova
Brza putanja to rešava dvofaznom, metapodacima vođenom injekcijom koja nikada ponovo ne obilazi reader. Prvi prolaz preko svih ulaza prikuplja, po dokumentu, root objekat outline-a i generacijske brojeve, prvi i poslednji top-level bookmark broj, i root-ov /Count. Iz tog sažetka kod računa globalne brojeve objekata svih linkova koje mora da isfabrikuje - svaki top-level /Parent ka zajedničkom root-u, prvi bookmark-ov /Prev ka poslednjem bookmark-u prethodnog dokumenta, poslednji bookmark-ov /Next ka prvom bookmark-u sledećeg dokumenta - koristeći čistu aritmetiku brojeva objekata. Iza ovoga postoji ograničenje redosleda upisa: objekti prvog dokumenta se upisuju pre nego što se otvori ijedan sledeći dokument, pa sve outline izmene prvog dokumenta (root /Count i /Last, i stari poslednji bookmark-ov /Next) moraju da budu izražive kao aritmetika kojoj kasniji dokument ne treba pri ruci. Sve izmene sledećih dokumenata primenjuju se na mestu nakon što se dokument otvori, ali pre nego što se upiše, pa izlaze kroz istu change-object putanju
Invarijanta poravnanja offseta koja sve to drži zajedno
I byte shift i bookmark injekcija zavise od jedne aritmetičke invarijante, i to je najkrhkija pretpostavka u celom dizajnu. Referenca ubrizgana u sledeći dokument piše se kao ciljani globalni broj objekta minus Offset tog dokumenta, tako da kada se objekat kasnije pomeri sa ShiftIndRef(Offset) vrednost padne na željeni globalni broj. Prvi dokument dobija Offset = 0 i koristi globalne brojeve direktno. Da bi to oduzimanje bilo tačno, sekvenca running offset-a korišćena tokom injekcije mora da se poklopi sa sekvencom offset-a korišćenom kada se objekti konačno upisuju
To i jeste tako, zbog svojstva načina na koji rade merge stranica i formi: AddPages, AddFields, i AddFieldFonts menjaju samo postojeće objekte prvog dokumenta - nikada ne dodaju nove. Zato broj objekata prvog dokumenta ostaje nepromenjen kroz fazu merge stranica, a offset svakog sledećeg dokumenta (zbir svih prethodnih brojeva objekata) ostaje stabilan od injekcije do upisa. Prekinite to - uvedite fazu koja stvara novi objekat usred merge-a - i svaka referenca stranice i bookmark-a nizvodno bila bi pogrešna za broj objekata koje ste dodali. Invarijanta je tiha, ali nosi opterećenje
Tri ulazne tačke preko jednog motora
Brza putanja nije viljuška merge koda. U istoj liniji rada, byte-level motor je izdvojen u jednu internu rutinu, MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode), a javni API-ji su postali tanki omotači koji biraju dve zastavice:
MergeFileListFastpoziva motor sa isključenim čuvanjem structure tree-a - najlakša putanja, odbacuje tagovani PDF tree tako da byte putanja važi za najviše objekataMergeFileListpoziva ga sa čuvanjem, tako da structure tree preživi i rezultat ostane upotrebljiv tagovani PDF. Ova obična putanja takođe nasleđuje višedokumentno spajanje bookmark-ova i form-ovaMergeFileListStrictuključuje strict mode: prvi metadata prolaz staje na prvom ulazu koji ne prijavljuje čist merge, pa se uključuju samo dokumenti prikupljeni pre lošeg fajla, umesto da se loš fajl preskoči i nastavi dalje
Spajanje putanja je takođe omogućilo da se obični merge pregradi iz pairwise O(N²) petlje - spoji fajl jedan i dva, spoji taj rezultat sa tri, i tako dalje, ponovo parsirajući rastući akumulator na svakom koraku - u jedan linearni prolaz koji svaki ulaz otvara samo jednom. Dve dugo prisutne dve-fajl i dve-stream ulazne tačke, MergeFiles i MergeStreams, ostaju netaknute i i dalje dostupne pozivaocima koji zaista žele pairwise merge
Jedna poštena napomena o ponašanju structure tree-a, jer je to ubolo testni suite. Fast path-ov "drop" nije potpun: uklanja catalog reference prvog dokumenta ka /StructTreeRoot, ali sam objekat structure tree-a i dalje biva upisan kao orphan. Zato brzi izlaz i dalje u bajtovima sadrži /StructTreeRoot string, i ne možete razlikovati brz i običan izlaz samo pretragom tog stringa - prava razlika je da li katalog i dalje doseže structure tree, a to upravo određuje da li je fajl i dalje navigabilan tagovani PDF
Kada posegnuti za kojom putanjom
The byte path is a throughput optimization for assembling many documents where you do not need the tagged-PDF structure tree preserved — report bundling, statement runs, batch concatenation. Measured over repeated merges of medium-to-large input sets, the byte reuse trimmed roughly four to thirteen percent off wall-clock time depending on object mix, with no new failures on small or malformed inputs, because any object the scanner cannot prove safe falls back to the full parse. If you do need the structure tree intact for accessibility, use the ordinary tagged-PDF merge path, which preserves it; and if you are working with very large single files rather than many inputs, the byte-copy techniques described in the companion piece on large PDF merge and split with direct file access apply the same "copy bytes, avoid the full object tree" philosophy at file scale
The merge routines and their fast and strict variants are part of the PDFlibPas Delphi PDF Library, whose documentation carries the full reference for the file-list API and the merge options described here