Spajanje PDF-ova zvuči kao da bi trebalo biti jeftino. Sadržaj stranica već je složen, fontovi su već ugrađeni, slike su već komprimirane. U načelu je merge samo knjigovodstvo: prenumerirajte objekte tako da se numeracijski prostori dviju datoteka prestanu sudarati, spojite stabla stranica, popravite cross-reference tablicu i zapišite. U praksi većina koda za merge tu jeftinoću baci kroz prozor. Za svaki objekt u svakoj ulaznoj datoteci radi potpuni parse u tokenizirano stablo objekata, izmijeni nekoliko neizravnih referenci, pa stablo ponovno serijalizira u bajtove. Parse i reserialize su skupi dijelovi, a za gole većine objekata daju niz bajtova gotovo identičan onome što je ušlo
PDFlibPas je nativni Object Pascal PDF engine za Delphi i C++Builder, a njegov brzi merge put postoji da preskoči taj krug gdje god je dokazivo sigurno. Ideja je uska, ali se isplati preko cijelih skupova dokumenata: za neizmijenjeni ne-stream objekt uzmi izvorne bajtove doslovno i napravi jedno byte-level prepisivanje neizravnih referenci koje sadrže, pretvarajući svako N G RN 0 R(N+Offset) G R u
. Nijedan tokenizer, nijedno stablo objekata, nijedan serializer. Ovaj članak prolazi kroz to gdje je taj prečac legalan, parser state machine koji obavlja byte rewrite bez kvarenja ičega, zašto je spajanje knjižnih oznaka tražilo potpuno drugačiji mehanizam i kako je običan merge path istodobno preuređen iz kvadratnog u linearni
Zašto je prenumeriranje objekata stvarni trošak spajanjaSvaki PDF nosi vlastiti prostor numeracije objekata. Datoteka A ima objekt 1, objekt 2 i tako dalje; datoteka B ima vlastiti objekt 1, objekt 2 i tako dalje. Ne možete B-ove objekte ubaciti u A-ovu datoteku nepromijenjene, jer bi se brojevi sudarili i svaka neizravna referenca unutar B-a sada bi pokazivala na pogrešan objekt. Rješenje je offset: ako A završava na broju objekta , onda B-ov objekt N postaje objekt N+Offset u izlazu, a svaka referenca N G R koja se pojavi bilo gdje unutar B-ovih objekata mora se pomaknuti na (N+Offset) G R da bi se poklopila
Taj pomak je cijeli semantički posao spajanja tijela. Popravci page treea i merge AcroForma mali su, ograničeni zahvati na nekolicini objekata. Glavni posao je prepisivanje referenci kroz tisuće objekata, a naivan način da se to napravi jest parsirati svaki objekt kako biste referencije mogli pronaći strukturno. PDFlibPasov MergeFileListFast uzima suprotan pristup: reference su pronađive i u sirovim bajtovima ako ste oprezni oko konteksta u kojima niz znamenka-razmak-znamenka-razmak-R nije ne referenca. Preskočite parse, pomaknite in place i trošak po objektu kolabira na jedan linearni prolaz kroz bajtove koje biste ionako kopirali
Kad je ponovno korištenje izvornih bajtova dokazivo sigurno
Byte path se koristi samo kada za objekt koji se kopira iz sljedećeg dokumenta vrijede sva tri uvjeta. Bilo koji neuspjeh vraća objekt na puni decode-and-reserialize put, pa sigurnost uvijek pobjeđuje brzinu:
Doc2.IsChangedObject(X)je False. Ako je merge engine već izmijenio objekt u memoriji (na primjer, page objekt čiji je/Parentpreusmjeren), in-memory stablo je izvor istine, a izvorni bajtovi su zastarjeli. Samo ne dirani objekti kvalificiraju se- Izvorni bajtovi ne sadrže
streamkeyword. Tijelo stream objekta je neprozirni binarni sadržaj uokviren sstream/endstream, a naivni reference scan nad komprimiranim ili šifriranim stream podacima rado će "pronaći" i korumpirati obrasce bajtova koji izgledaju kao reference. Stream objekti ostaju na starom stream-aware putu - Izvorni bajtovi ne sadrže ni
/StructTreeRootni/StructElem. U brzom profilu tagged-PDF strukturirano stablo se odbacuje umjesto da se spaja, pa ti objekti moraju kroz decode path gdje ih engine može namjerno obrisati
Odluka živi u petlji kopiranja po objektu. Kada sva tri provjera prođu, bytes objekta idu ravno u ShiftIndRefsInSource pa zatim u writer; inače se bajtovi odbacuju i objekt se ponovno gradi s GetObject, pomiče s ShiftIndRef, i serijalizira. Struktura te grane vrijedi se vidjeti, jer redoslijed provjera je ono što je čini sigurnom:
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 byte path nije prihvatio objekt. Taj jedan sentinel drži brzu i sporu rutu odmaknutima: postoji točno jedno mjesto koje odlučuje, i točno jedan fallback
State machine za pomicanje referenci i njezine rubne slučajeve
Byte rewrite neizravnih referenci lako je pogriješiti, jer R i nizovi znamenki pojavljuju se posvuda u PDF objektu u kontekstima koji nisu reference. ShiftIndRefsInSource je mali ručno pisan skener koji prolazi kroz bajtove jednom i prepisuje broj samo kada iza njega, uz PDF whitespace između tokena, dolazi drugi broj i zatim R delimiter. Jeftini izlazi dolaze prvi: ako je offset nula ili je izvor prazan, bajtovi se vraćaju netaknuti bez ikakvog ulaska u skener
Ispravnost skenera počiva na prepoznavanju konteksta u kojima se niz nalik referenci mora ostaviti na miru. To su rubovi koje je najlakše propustiti, a svaki od njih obrađuje se izričito:
- Literal strings omeđeni s
(i)kopiraju se doslovno, uz praćenje dubine ugnježđivanja i poštovanje backslash escapea tako da escapana zagrada ne poremeti broj dubine. String poput(see object 3 0 R for details)sadrži školski uzorak reference koji je zapravo samo proza, i mora preživjeti bajt-po-bajt - Hexadecimal strings omeđeni s
<i>prolaze bez interpretacije. Bajtovi52unutar hex stringa ASCII su kod zaR, a skener koji bi hex payload tretirao kao tekst mogao bi proizvesti phantom referencu. Otvaranje<<rječnika otkriva se prvo kako se rječnik ne bi zamijenio za hex string - Name objects koji počinju s
/obrađuju se cijeli, od kosa crte do sljedećeg whitespacea ili delimiter-a. Bez toga bi se naziv poput/R(uobičajeni resource key) mogao pročitati kaoRreference - Comments uvedeni s
%idu do kraja retka i preskaču se kao neprozirni tekst - Broj-then-R test je strog. Referenca se prepoznaje samo kao
NwhitespaceGwhitespaceRsRzavršenim whitespaceom, delimiterom ili krajem ulaza. Ako broj generacije nedostaje, iliRslijedi slovo, znamenke se ispisuju nepromijenjene. To je ono što štiti integer u/Length 1234i četiri brojaMediaBoxod tihog povećavanja
Srce tog strogog testa gotovo točno odgovara rečenici 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; broj generacije i točan izvorni whitespace između tokena kopiraju se kroz, pa je izlaz bajt-identičan ulazu osim jednog integera koji je morao promijeniti. Ta preciznost je cijela poanta - ona čini ponovno korištenje izvornih bajtova jednakim punoj reserializaciji, a ne samo blizu tome. Ponašanje pokriva fokusirani skup unit testova koji vježbaju gole reference, reference unutar nizova, brojeve koji nisu reference, literal strings, hex strings i nenulte brojeve generacije s primijenjenim offsetom
Zašto knjižne oznake nisu mogle koristiti AppendOutline
Spajanje bookmarkova više dokumenata u jedno outline stablo izgleda kao posao za postojeći AppendOutline helper, koji već zna kako graftati top-level bookmarke jednog dokumenta na drugi. Ovdje je to pogrešan alat, a razlog je suptilni mismatch slojeva. AppendOutline locira trenutnu zadnju top-level knjižnu oznaku prolazeći čitačem preko izvornih bajtova datoteke. Ali fast merge uređuje svoje izmjene u new-objects bufferu kroz ChangeObject; čitač te izmjene nikad ne vidi. Spojite tri ili više dokumenata i svaki append preusmjeri prvi dokumentov izvorni zadnji bookmark na najnoviji dokument, pa svi međudokumentski bookmarkovi ispadaju iz lanca - samo kumulativni /Count ostaje točan, što bug čini lakim za promašiti sve dok netko ne otvori panel bookmarkova
Brzi put to rješava dvofaznom, metapodacima vođenom injekcijom koja nikad ne prolazi ponovno kroz čitač. Prvi prolaz preko svih ulaza prikuplja, po dokumentu, outline root objekt i brojeve generacije, prvi i zadnji top-level broj bookmarka i rootov /Count. Iz tog sažetka kod računa globalne brojeve objekata svih veza koje treba iskovati - svaki dokumentov top-level /Parent do zajedničkog roota, prvi bookmarkov /Prev do prethodnog dokumentovog zadnjeg, zadnji bookmarkov /Next do sljedećeg dokumentovog prvog - koristeći čistu aritmetiku brojeva objekata. Iza toga stoji uvjet redoslijeda pisanja: objekti prvog dokumenta ispisuju se prije nego što se ijedan sljedeći dokument uopće otvori, pa sve izmjene outlinea prvog dokumenta (root /Count i /Last, i stari zadnji bookmarkov /Next) moraju biti izražive kao aritmetika kojoj nijedan kasniji dokument nije potreban pri ruci. Izmjene svakog sljedećeg dokumenta primjenjuju se in place nakon što se otvori, ali prije nego što se upiše, pa izlaze kroz isti change-object path
Invarijanta poravnanja offseta koja sve to veže
I reference-shift i injekcija bookmarkova ovise o jednoj aritmetičkoj invarijanti, i to je najkrhkija pretpostavka u cijelom dizajnu. Referenca injektirana u sljedeći dokument piše se kao ciljani globalni broj objekta minus taj dokumentov Offset, tako da kada se objekt kasnije pomakne s ShiftIndRef(Offset) vrijednost sjedne na predviđeni globalni broj. Prvi dokument dobiva Offset = 0 i koristi globalne brojeve izravno. Da bi to oduzimanje bilo ispravno, niz offseta koji se koristi tijekom injekcije mora odgovarati nizu offseta koji se koristi kada se objekti napokon ispisuju
To jest, i to zbog svojstva načina na koji rade page i form mergeovi: AddPages, AddFields i AddFieldFonts mijenjaju samo postojeće objekte prvog dokumenta - nikad ne dodaju nove. Dakle, broj objekata prvog dokumenta ostaje nepromijenjen kroz fazu page-mergea, a offset svakog sljedećeg dokumenta (zbroj svih prethodnih brojeva objekata) ostaje stabilan od injekcije do izlaza. Prekršite to - uvedite fazu koja usred mergea stvara novi objekt - i svaka stranica i bookmark referenca nizvodno bila bi pomaknuta za broj objekata koji ste dodali. Invarijanta je tiha, ali nosiva
Tri ulazne točke nad jednim engineom
Brzi put nije fork merge koda. U istom poslu byte-level engine je izdvojen u jednu internu rutinu, MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode), a javni API-ji postali su tanke ovojnice koje biraju dvije zastavice:
MergeFileListFastpoziva engine sa struktur-tree preservation isključenim - najlakši put, koji odbacuje tagged-PDF stablo tako da byte ruta može vrijediti za najviše objekataMergeFileListpoziva ga s preservation uključenim, pa strukturalno stablo preživljava i rezultat ostaje upotrebljiv tagged PDF. Taj običan put također nasljeđuje multi-document bookmark i form mergingMergeFileListStrictuključuje strict mode: prvi metadata prolaz staje na prvom ulazu koji ne prijavi čist merge, pa se uključuju samo dokumenti prikupljeni prije loše datoteke, umjesto da se loša datoteka preskoči i nastavi dalje
Spajanje staza zajedno omogućilo je i da se običan merge preuredi iz pairwise O(N²) petlje - merge file jedan i dva, pa rezultat s tri, i tako dalje, reparseajući rastući akumulator svakim korakom - u jedan linearni prolaz koji svaki ulaz otvara jednom. Dvije dugogodišnje dvo-datotečne i dvo-stream ulazne točke, MergeFiles i MergeStreams, ostaju netaknute i dostupne pozivateljima koji doista žele pairwise merge
Jedna poštena napomena o ponašanju struktur-treea, jer je pogodila test suite. Fast path "drop" nije totalan: uklanja katalog prvog dokumentaovog reference na /StructTreeRoot, ali sam objekt struktur-treea i dalje se ispiše kao orphan. Tako bajtovi brzog izlaza i dalje sadrže string /StructTreeRoot, i ne možete razlikovati brzi od običnog izlaza traženjem tog stringa - stvarna razlika je u tome doseže li katalog još uvijek struktur tree, a to određuje ostaje li datoteka navigabilni tagged PDF
Kada posegnuti za kojom stazom
Byte path je optimizacija propusnosti za sastavljanje mnogo dokumenata kada vam tagged-PDF strukturalno stablo ne treba sačuvano - bundling izvještaja, serije obračuna, batch concatenation. Mjereno na ponovljenim mergeovima srednje velikih i velikih skupova ulaza, byte reuse je skratio otprilike četiri do trinaest posto wall-clock vremena ovisno o mješavini objekata, bez novih kvarova na malim ili neispravnim ulazima, jer svaki objekt za koji skener ne može dokazati sigurnost pada natrag na puni parse. Ako trebate struktur tree netaknut zbog pristupačnosti, koristite obični tagged-PDF merge path, koji ga čuva; a ako radite s vrlo velikim pojedinačnim datotekama umjesto s mnogo ulaza, byte-copy tehnike opisane u pratećem članku o veliko spajanje i dijeljenje PDF-a s izravnim pristupom datoteci 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