Tehnički članak

Kopiranje PDF objekata među dokumentima u Delphiju: ciklusi

Spojite dva PDF-a ručno, premjestite jedan objekt stranice u ciljni dokument, i kopija odmah upadne u access violation. PDFlibPas ovo rješava u CopyForeignObject: duboko kopira jedan indirektan objekt i sve reference koje on povlači za sobom, a ciklične povratne reference poput /Parent razrješuje u null umjesto da rekurzira

Zašto kopiranje jedne stranice među dokumentima ruši program?

Jer PDF stablo stranica stablo je samo ako ga čitate nadolje. Prošetajte njime onako kako to čini rekurzivni kopirač, prateći svaku vrijednost u svakom rječniku, i rječnik stranice vam uruči /Parent, koji pokazuje natrag na /Pages čvor iz kojega ste stigli, a taj čvor vam uruči /Kids, koji pokazuje natrag na stranicu. ISO 32000-1 §7.7.3 zahtijeva /Parent na svakom čvoru stabla stranica osim korijena, pa ovo nije deformirana datoteka koju možete odbiti — to je normalan oblik svakog dokumenta koji vam ikada stigne u ruke

Druga polovica problema je numeriranje. Indirektni objekti identificiraju se brojem objekta koji je lokalnog opsega za jednu datoteku (ISO 32000-1 §7.3.10), pa objekt prevučen iz dokumenta A u dokument B mora dobiti novi broj, i svaka referenca na njega unutar kopirane skupine mora se numerirati na isti način, inače će dvije reference koje su upućivale na jedan zajednički font upućivati na dva nepovezana pojma. To prenumeriranje isti je posao koji brzi merge radi na razini bajtova, i vrijedi ta dva pristupa čitati usporedo: pomak referenci na razini bajtova za brzi PDF merge rješava ga prevođenjem cijelih datoteka, dok kopija na razini objekata mora rješavati granu po granu

Zašto kopija PDF-a između dokumenata u Delphiju traži oprez: rječnik stranice i njegov /Pages čvor zatvaraju ciklus kroz /Parent i /Kids, skupina fonta teče nadolje i završava, a PDFlibPas preslikava svaki broj objekta lokalnog za datoteku
Stablo stranica zatvara petlju kroz /Parent i /Kids dok se sadržajne skupine završavaju, i svaki kopirani broj objekta mora se usput preslikati

Što CopyForeignObject u PDFlibPasu stvarno kopira

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonira jedan indirektan objekt i sve do čega se od njega može doći — ugniježđene rječnike, nizove, znakovne nizove, imena, brojeve i tokove s njihovim netaknutim rječnicima — u trenutačno odabrani dokument, i vraća handle različit od nule na novu indirektnu referencu. Izvorni brojevi objekata preslikavaju se kroz živu mapu koja se drži tijekom trajanja poziva, pa je objekt koji se u skupini posjeti dvaput kloniran jednom i podijeljen dvaput. Vraća nulu, bez podizanja iznimke, kad je ID izvornog dokumenta nepoznat, kad je izvor sam odabrani dokument ili kad je ObjectNumber ispod 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile vraća 1 na uspjeh
    SourceDoc := Lib.SelectedDocument;   // učitavanje je odabralo ono što je učitalo
    Lib.SelectDocument(TargetDoc);       // kopija cilja odabrani dokument
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Dvije pojedinosti ujedu prilikom prvog pokretanja. LoadFromFile odgovara s 1 ili 0, a ne s ID-om dokumenta, pa handle koji vam treba dolazi iz SelectedDocument odmah nakon učitavanja; i kopija uvijek piše u ono što je SelectDocument posljednje učinilo trenutačnim, nikad u dokument iz kojega ste učitali. Izvodno rekurzija nosi i kruti strop dubine od 64, što je zaštitna mreža protiv patološkog gniježđenja, a ne mehanizam koji rukuje ciklusima — rukovanje ciklusima zasebno je i namjerno

Zašto rezerviranje Nil mapiranja ne prekida ciklus?

Jer Nil u tablici mapiranja znači dvije različite stvari istovremeno, i kod ih ne zna razlikovati. Očigledna obrana od ciklusa jest dodati stavku mape prije rekurzije u objekt, da sve što se vrati unatrag nađe stavku i stane. Ali stavka još ne može držati pravi cilj — cilj ne postoji dok se ne napiše skupina ispod njega — pa drži Nil, i pretraga koja bi trebala uhvatiti povratnu granu pročita Nil i zaključi da objekt nikad nije mapiran

// Pokvareno: rezervirani Nil cilj nerazlučiv je od "još nije mapirano"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezervirano, još Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // dopisano tek pri izlasku
end;

Pratite to kroz petlju stranice. Klon stranice dođe do /Parent, rekurzira u /Pages čvor, koji dođe do /Kids, koji rekurzira natrag u stranicu — čija rezervirana stavka još uvijek glasi Nil, pa se klonira drugi put, i treći, svaka razina gura svježi okvir i svjež napola izgrađen objekt. Ono što opazite nije ni čist stack overflow: vanjski okviri sjede na referencama čiji ciljevi nikad nisu dodijeljeni, pa prvi zapis kroz jedan od tih slotova jest access violation negdje gdje ničim ne liči na kopiranje stranice koje ga je prouzročilo

Zašto rezerviranje Nil cilja u mapi ne zaustavlja ciklus u PDFlibPas kopiji između dokumenata: pretraga ne razlikuje rezerviranu stavku od nemapirane, pa kopirač silazi kroz sve dublje napola izgrađene okvire dok zapis ne sruši program
Budući da Nil cilj odgovara na dva različita pitanja odjednom, povratna grana nikad nije prepoznata i stranica se ponovno klonira u svakom prolazu

Popravak: izričito in-progress stanje

Popravak je prestati preopterećivati Nil i pitati pitanje izravno. Stavka mape čiji cilj još nije dodijeljen znači ovaj se objekt upravo klonira, a predikat InProgress testira upravo to prije nego što se pokrene obična pretraga. Kad je istinit, grana je ciklus natrag u pretka trenutačnog klona, i PDFlibPas za nju ispušta null objekt umjesto da je prati

// Stavka mape s Nil ciljem označava klon u tijeku
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... unutar CloneObject, za indirektnu referencu:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // ciklična povratna grana, ne rekurziraj
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // viseća izvorna referenca
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezerviraj, pa rekurziraj
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // dopuni unatrag
end;
Exit(NewRef);

Ovo je sigurno generalizirati samo zbog strukturne činjenice o PDF-u: ciklusi u grafu objekata pojavljuju se na povratnim vezama, a ne na sadržajnim granama. /Parent u stablu stranica i /Prev u lancu outline-a pokazuju prema gore ili natrag na nešto već posjećeno; skupina fonta, image XObject-a ili form XObject-a teče nadolje i završava. Zato kopija font deskriptora, prostora boja ili shading rječnika nije pogođena null zamjenom — ništa u tim skupinama ne udari u InProgress. Cijena, rečeno otvoreno, jest da ciklična grana ne preživi kopiju. Rječnik stranice kloniran ovako dolazi s /Parent kao null objektom, što ISO 32000-1 §7.3.9 izjednačuje s odsutnom stavkom, pa je kopirana stranica valjan objekt koji ne pripada nijednom stablu stranica dok je sami ne povežete u ciljni /Pages čvor i ne ispravite /Count. Kopirana outline stavka gubi /Prev na isti način i treba ponovno izgraditi lanac stavki iste razine. To je pošten kompromis: CopyForeignObject daje vam ispravnu skupinu i prepušta strukturno ponovno povezivanje s roditeljem pozivatelju, što je ista granica unutar koje radi i zamjena stranica uz očuvanje brojeva objekata

Popravak u PDFlibPas CopyForeignObject za Delphi: izričit InProgress test izvodi se prije pretrage mape, ciklična povratna grana postaje null objekt, a pozivatelj naknadno ponovno povezuje kopiranu stranicu u ciljno stablo stranica
Izričito in-progress stanje zamjenjuje preopterećeni Nil, pa se povratna grana razrješuje u null, a pozivatelju ostaje jedna strukturna popravka za napraviti

Zašto stavku mape treba rezervirati prije NewObjRef

Očigledna alternativa izbjegla bi cijeli in-progress ples: najprije alocirati prazan objekt-ljusku, upisati njegov stvarni broj u mapu, pa ispuniti ljusku kad se kloniraju djeca. To ovdje ne radi, jer je TPDFIndObj.Obj read-only i njegov se sadržaj ne može zamijeniti nakon konstrukcije — nema ljuske koju bi se popunilo. Broj i sadržaj odlučeni su zajedno u NewObjRef, što znači da stavka mape mora nastati prije rekurzivnog poziva i biti dovršena nakon njega, i razmak između ta dva trenutka jest upravo ono što InProgress mora pokrivati. Jedna posljedica vrijedi poznavanja prije nego što usporedite izlaz: budući da NewObjRef izvodi nakon što se napiše skupina djece, numeriranje u cilju izlazi odozdo prema gore, i brojevi objekata neće zrcaliti redoslijed izvora. Formatu datoteke je to svejedno, ali usporedba bajtova s ručno sastavljenim očekivanjem nije. Ako izvođenje ostavi objekte koje ste odlučili nikamo povezati, oni su nereferencirani, a ne oštećeni, i mark-and-sweep skupljanje nedostižnih PDF objekata alat je koji ih očisti prije spremanja

Regresija koja ovo pokriva treba jednu pojedinost koja iznenađuje ljude koji pišu testove protiv TPDFlib: konstruktor već drži zadani dokument, pa DocumentCount kreće od 1 i dvodokumentni fixture mora tvrditi >= 2, a ne = 2. Uz uspješnu kopiju, test pribija tri odbijanja — nepoznat ID izvora, odabrani dokument kao vlastiti izvor i broj objekta nula — sva vraćaju 0 umjesto da dižu iznimku, jer petlja spajanja loše je mjesto da se otkrije da guard klauzula baca

Gdje se to uklapa u cjevovod spajanja

Kopiranje na razini objekata primitiv je do koje posežete kad je spajanje cijelih datoteka pregrubo: izvaditi jedan font program iz predloška, povući jedan form XObject u dokument za žigosanje ili premjestiti anotaciju s njenim appearance tokovima između datoteka bez vučenja ostatka stranice. PDFlibPas je izlaže kao jedan poziv nad učitanim dokumentima, a kako stoji uz ostatak niskorazinskog objektnog API-ja možete vidjeti u referenci PDFlibPas Delphi PDF Library