Spojite dva PDF-a ručno, premestite jedan objekat stranice u ciljni dokument, i kopija odmah završi u access violation. PDFlibPas ovo rešava u CopyForeignObject: duboko kopira jedan indirektni objekat zajedno sa celim njegovim skupom referenci, a ciklične povratne reference poput /Parent razrešava na null umesto da nastavi rekurziju
Zašto kopiranje jedne stranice između dokumenata pada?
Jer je PDF stablo stranica stablo samo ako ga čitate nadole. Obilazite ga onako kako radi rekurzivni kopijač, prateći svaku vrednost u svakom rečniku, i rečnik stranice vam pruža /Parent, koji pokazuje nazad na /Pages čvor iz koga ste došli, a taj čvor vam pruža /Kids, koji pokazuje nazad na stranicu. ISO 32000-1 §7.7.3 zahteva /Parent na svakom čvoru stabla stranica osim korena, pa ovo nije neispravan fajl koji možete odbiti — to je normalan oblik svakog dokumenta koji vam ikada bude uručen
Druga polovina problema je numerisanje. Indirektni objekti se identifikuju brojem objekta koji je lokalnog za jedan fajl (ISO 32000-1 §7.3.10), pa objekat prevučen iz dokumenta A u dokument B mora biti prenumerisan, i svaka referenca na njega unutar kopiranog skupa mora biti prenumerisana na isti način, ili dve reference koje su ukazivale na jedan deljeni font sada ukazuju na dve nepovezane stvari. To prenumerisanje je isti posao koji brzi merge radi na nivou bajtova, i vredi pročitati oboje uporedo: pomeranje referenci na nivou bajtova za brzi PDF merge to rešava prevodom celih fajlova, dok kopija na nivou objekata mora da to rešava granu po granu
Šta PDFlibPas CopyForeignObject stvarno kopira
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonira jedan indirektni objekat i sve do čega se može doći od njega — ugnježdene rečnike, nizove, stringove, imena, brojeve i streamove sa njihovim rečnicima netaknutim — u trenutno selektovani dokument, i vraća nenulti handle na novu indirektnu referencu. Brojevi izvornih objekata se preslikavaju kroz živu mapu koja se drži tokom trajanja poziva, pa objekat do koga se u skupu dođe dva puta biva kloniran jednom i deljen dvaput. Vraća nulu, bez dizanja izuzetka, kada je ID izvornog dokumenta nepoznat, kada je izvor sam selektovani dokument, ili kada 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 uspeh
SourceDoc := Lib.SelectedDocument; // učitavanje samo selektuje učitani dokument
Lib.SelectDocument(TargetDoc); // kopija cilja selektovani dokument
Handle := Lib.CopyForeignObject(SourceDoc, 12);
if Handle = 0 then
raise Exception.Create('cross-document copy rejected');
finally
Lib.Free;
end;
end;
Dva detalja ujedu ljude pri prvom pokretanju. LoadFromFile odgovara 1 ili 0, a ne ID dokumenta, pa handle koji vam treba dolazi iz SelectedDocument odmah posle učitavanja; i kopija uvek upisuje u ono što je SelectDocument poslednje proglasio tekućim, nikad u dokument iz koga ste učitali. Interno rekurzija nosi i čvrstu granicu dubine od 64, koja je poslednja odbrana od patološkog ugnježdavanja, a ne mehanizam koji obrađuje cikluse — obrada ciklusa je odvojena i namerna
Zašto rezervisanje Nil mapiranja ne prekida ciklus?
Jer Nil u tabeli mapiranja znači dve različite stvari odjednom, a kod ne zna da ih razluči. Očigledna odbrana od ciklusa je da se unos mape doda pre rekurzije u objekat, tako da sve što se vrati unazad nađe unos i stane. Ali unos još ne može da drži pravi cilj — cilj ne postoji dok se skup ispod njega ne upiše — pa drži Nil, i pretraga koja bi trebalo da uhvati povratnu granu pročita Nil i zaključi da objekat nikad nije mapiran
// Pokvareno: rezervisan Nil cilj je nerazlikoviv 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; // rezervisano, još uvek Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // popunjava se 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 nazad u stranicu — čiji rezervisani unos i dalje glasi Nil, pa se klonira drugi put, i treći, svaki nivo gurajući novi frejm i novi napola sagrađen objekat. Ono što posmatrate nije ni čist stack overflow: spoljašnji frejmovi sede na referencama čiji ciljevi nikad nisu dodeljeni, pa prvi upis kroz jedan od tih slotova je access violation negde što ne liči ni na čemu na kopiju stranice koja ga je izazvala
Popravka: eksplicitno stanje u toku
Popravka je da se prestane sa preopterećivanjem Nil i pitanje postavi direktno. Unos mape čiji cilj je još nedodeljen znači ovaj objekat se trenutno klonira, a InProgress predikat testira upravo to pre nego što obična pretraga krene. Kada je true, grana je ciklus nazad u pretka tekućeg klona, i PDFlibPas za nju emituje null objekat umesto da je prati
// Unos mape sa Nil ciljem označava klon u toku
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 ulaziti u rekurziju
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; // rezerviši, pa rekurziraj
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // popuni unazad
end;
Exit(NewRef);
Ovo je bezbedno za generalizaciju samo zbog strukturne činjenice o PDF-u: ciklusi u grafu objekata javljaju se na povratnim vezama, a ne na granama sadržaja. /Parent u stablu stranica i /Prev u lancu outline-a ukazuju nagore ili unazad na nešto već posećeno; skup referenci fonta, image XObject-a ili form XObject-a ide nadole i završava se. Pa je kopija font deskriptora, color space-a ili shading rečnika netaknuta null zamenom — ništa u tim skupovima ne dotakne InProgress. Cena, rečeno direktno, je da ciklična grana ne preživi kopiju. Rečnik stranice kloniran ovako dolazi sa /Parent kao null objektom, što ISO 32000-1 §7.3.9 izjednačava sa odsutnim unosom, pa je kopirana stranica validan objekat koji ne pripada nijednom stablu stranica dok ga sami ne povežete u ciljni /Pages čvor i sami ne sredite /Count. Kopirani outline element gubi /Prev na isti način i traži ponovnu izgradnju sestrinskog lanca. To je poštena trampa: CopyForeignObject vam daje ispravan skup referenci i ostavlja pozivaocu strukturno povezivanje sa novim roditeljem, što je ista granica u kojoj radi zamena stranica uz očuvanje brojeva objekata
Zašto unos mape mora biti rezervisan pre NewObjRef
Očigledna alternativa bi zaobišla ceo ples sa stanjem u toku: prvo alocirati prazan shell objekat, registrovati njegov pravi broj u mapi, pa ispuniti shell kad se deca kloniraju. To ovde ne radi, jer je TPDFIndObj.Obj read-only i njegov sadržaj ne može biti zamenjen posle konstrukcije — nema shell-a koji se puni. Broj i sadržaj se odlučuju zajedno u NewObjRef, što znači da unos mape mora biti stvoren pre rekurzivnog poziva i dopunjen posle njega, a interval između ta dva trenutka je upravo ono što InProgress mora pokriti. Jedna posledica vredi poznavanja pre nego što uporedite izlaz: pošto se NewObjRef izvršava posle upisa dečjeg skupa referenci, numerisanje u cilju izlazi odozdo nagore, i brojevi objekata neće ogledati redosled izvora. Format fajla nije ništa od toga briga, ali bajt-uporedba sa ručno sastavljenim očekivanjem jeste. Ako pokretanje ostavi objekte za koje ste odlučili da ih ni u šta ne povežete, oni su nereferencirani, a ne oštećeni, i mark-and-sweep sakupljanje nedostižnih PDF objekata je alat koji ih počisti pre čuvanja
Regresija koja ovo pokriva traži jedan detalj koji iznenađuje ljude koji pišu testove protiv TPDFlib: konstruktor već drži podrazumevani dokument, pa DocumentCount počinje od 1 i fixture sa dva dokumenta mora asertirati >= 2, a ne = 2. Uz uspešnu kopiju, test zabija tri odbijanja — nepoznat ID izvora, selektovani dokument kao svoj vlastiti izvor i broj objekta nula — sva vraćaju 0 umesto da dižu izuzetak, jer je merge petlja loše mesto da otkrijete da guard klauzala baca
Gde ovo stoji u merge pipeline-u
Kopiranje na nivou objekata je primitiv za koju posežete kada je merge celih fajlova pregrub: izvlačenje jednog font programa iz šablona, povlačenje jednog form XObject-a u dokument za žigosanje, ili premeštanje anotacije sa njenim appearance streamovima između fajlova bez vlčenja ostatka stranice. PDFlibPas ga izlaže kao jedan poziv nad učitanim dokumentima, a kako stoji uz ostatak niskonivo Object API-ja vidite u referenci PDFlibPas Delphi PDF Library