Tehnički članak

Brzo spajanje PDF-ova u Delphiju: pomicanje referenci na razini bajta

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 /Parent preusmjeren), in-memory stablo je izvor istine, a izvorni bajtovi su zastarjeli. Samo ne dirani objekti kvalificiraju se
  • Izvorni bajtovi ne sadrže stream keyword. Tijelo stream objekta je neprozirni binarni sadržaj uokviren s stream/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 /StructTreeRoot ni /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. Bajtovi 52 unutar hex stringa ASCII su kod za R, 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 kao R reference
  • 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 N whitespace G whitespace R s R završenim whitespaceom, delimiterom ili krajem ulaza. Ako broj generacije nedostaje, ili R slijedi slovo, znamenke se ispisuju nepromijenjene. To je ono što štiti integer u /Length 1234 i četiri broja MediaBox od 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:

  • MergeFileListFast poziva 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 objekata
  • MergeFileList poziva 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 merging
  • MergeFileListStrict uključ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