Odborný článok

Kopírovanie PDF objektov medzi dokumentmi v Delphi

Ručné zlučovanie dvoch PDF, presun jediného objektu strany do cieľového dokumentu — a kópia narazí na prístupové porušenie. PDFlibPas to rieši v CopyForeignObject: hlboko kopíruje jeden nepriamy objekt aj s celým jeho referenčným uzavretím a cyklické spätné odkazy ako /Parent rozlíši na null namiesto rekurzie

Prečo kopírovanie jednej strany medzi dokumentmi padá?

Pretože strom strán PDF je strom iba vtedy, keď ho čítate smerom nadol. Prechádzajte ho tak, ako to robí rekurzívny kopírovač, sledujúc každú hodnotu v každom slovníku, a slovník strany vám podá /Parent, ktorý ukazuje späť na uzol /Pages, z ktorého ste prišli, a ten podá /Kids, ktorý ukazuje späť na stranu. ISO 32000-1 §7.7.3 vyžaduje /Parent na každom uzle stromu strán okrem koreňa, takže nejde o poškodený súbor, ktorý môžete odmietnuť — je to normálny tvar každého dokumentu, aký vám kedy príde do rúk

Druhá polovica problému je číslovanie. Nepriame objekty sa identifikujú číslom objektu, ktoré je lokálne pre jeden súbor (ISO 32000-1 §7.3.10), takže objekt pretiahnutý z dokumentu A do dokumentu B musí byť prečíslovaný, a každá referencia naň v kopírovanom uzavretí musí byť prečíslovaná rovnakým spôsobom, inak dve referencie, ktoré kedysi ukazovali na jeden zdieľaný font, ukážu na dve nesúvisiace veci. To prečíslovanie je tá istá práca, ktorú rýchle zlúčenie robí na úrovni bajtov, a oplatí sa čítať obe vedľa seba: posun referencií na úrovni bajtov pri rýchlom zlúčení PDF rieši problém prekladom celých súborov, zatiaľ čo kopírovanie na úrovni objektov ho musí riešiť po jednej hrane

Prečo si kopírovanie PDF medzi dokumentmi v Delphi vyžaduje pozornosť: slovník strany a jeho uzol /Pages uzatvárajú cyklus cez /Parent a /Kids, uzavretie fontu beží nadol a končí a PDFlibPas premapuje každé lokálne číslo objektu
Strom strán uzatvára slučku cez /Parent a /Kids, kým obsahové uzavretia končia, a každé kopírované číslo objektu musí byť na ceste premapované

Čo PDFlibPas CopyForeignObject naozaj kopíruje

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonuje jeden nepriamy objekt a všetko, čo je z neho dosiahnuteľné — vnorené slovníky, polia, reťazce, názvy, čísla a streamy aj s ich slovníkmi — do aktuálne vybraného dokumentu a vracia nenulový handle na novú nepriamu referenciu. Zdrojové čísla objektov sa premapovávajú cez živú mapu udržiavanú počas trvania volania, takže objekt dosiahnutý dvakrát v uzavretí sa klonuje raz a zdieľa dvakrát. Vracia nulu, bez vyvolania výnimky, keď je ID zdrojového dokumentu neznáme, keď je zdrojom samotný vybraný dokument, alebo keď je ObjectNumber menšie ako 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 vracia 1 pri úspechu
    SourceDoc := Lib.SelectedDocument;   // načítanie vybralo, čo načítalo
    Lib.SelectDocument(TargetDoc);       // kópia smeruje do vybraného dokumentu
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Dva detaily ľudí poštipnú pri prvom spustení. LoadFromFile odpovedá 1 alebo 0, nie ID dokumentu, takže handle, ktorý potrebujete, beriete z SelectedDocument hneď po načítaní; a kópia sa vždy zapisuje do dokumentu, ktorý SelectDocument naposledy urobil aktuálnym, nikdy do dokumentu, z ktorého ste načítali. Interně rekurzia tiež nesie pevný strop hĺbky 64, čo je poistka proti patologickému zanoreniu, nie mechanizmus, ktorý spravuje cykly — správa cyklov je samostatná a zámerná

Prečo vyhradenie mapovania Nil nerozbije cyklus?

Pretože Nil v tabuľke mapovania znamená naraz dve rôzne veci a kód ich nedokáže odlíšiť. Zjavná obrana proti cyklu je pridať položku mapy pred rekurziu do objektu, aby všetko, čo sa vráti späť, našlo položku a zastavilo sa. Ale položka zatiaľ nemôže držať skutočný cieľ — ten neexistuje, kým sa nezapíše uzavretie pod ňou — takže drží Nil a vyhľadávanie, ktoré má zachytiť spätnú hranu, prečíta Nil a uzavrie, že objekt nikdy nezmapovaný nebol

// Pokazené: vyhradený cieľ Nil sa nedá odlíšiť od „zatiaľ nezmapované"
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;          // vyhradené, stále Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // doplnené až na spišnej ceste
end;

Sledujte to cez slučku strán. Klon strany dosiahne /Parent, rekurzuje do uzla /Pages, ten dosiahne /Kids, ktorý rekurzuje späť do strany — ktorej vyhradená položka stále číta Nil, takže sa klonuje druhýkrát, tretíkrát, každá úroveň tlačí nový rámec a nový polotovar. Nie je to ani čisté pretečenie zásobníka: vonkajšie rámce sedia na referenciách, ktorých ciele nikdy nepriradené, takže prvý zápis cez niektorý z týchto slotov je prístupové porušenie niekde úplne inde, ako kópia strany, ktorá ho spôsobila

Prečo vyhradenie cieľa Nil nezastaví cyklus pri kopírovaní PDFlibPas medzi dokumentmi: vyhľadávanie nedokáže odlíšiť vyhradenú položku od nezmapovanej, takže kopírovač zostupuje cez stále hlbšie polotovarové rámce, kým zápis nespadne
Keď cieľ Nil odpovedá naraz na dve rôzne otázky, spätná hrana sa nikdy nerozpozná a strana sa klonuje znova pri každom priechode

Oprava: explicitný stav in-progress

Oprava spočíva v tom, prestať preťažovať Nil a položiť si otázku priamo. Položka mapy, ktorej cieľ je stále nepriradený, znamená tento objekt sa práve klonuje a predikát InProgress testuje presne to ešte pred bežným vyhľadávaním. Keď je pravda, hrana je cyklus späť do predka aktuálneho klonu a PDFlibPas pre ňu emituje null objekt namiesto sledovania

// Položka mapy s cieľom Nil označuje prebiehajúci klon
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;

// ... vo vnútri CloneObject, pre nepriamu referenciu:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // cyklická spätná hrana, nerekurzovať
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);            // visiaca zdrojová referencia
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // vyhradiť, potom rekurzovať
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // doplniť
end;
Exit(NewRef);

Bezpečné zovšeobecnenie je to len vďaka štrukturálnej skutočnosti o PDF: cykly v grafe objektov sa objavujú na spätných odkazoch, nie na obsahových hranách. /Parent v strome strán a /Prev v reťazci osnovy ukazujú nahor alebo dozadu na niečo už navštívené; uzavretie fontu, obrazového XObjectu alebo formulárového XObjectu beží nadol a končí. Takže kópia deskriptora fontu, farebného priestoru alebo slovníka tieňovania nie je náhradou null dotknutá — nič v týchto uzavretiach nikdy nenarazí na InProgress. Náklady, povedané priamo, sú v tom, že cyklická hrana kópiu neprežije. Slovník strany klonovaný týmto spôsobom prichádza s /Parent ako null objektom, čo ISO 32000-1 §7.3.9 robí ekvivalentom chýbajúcej položky, takže skopírovaná strana je platný objekt, ktorý nepatrí do žiadneho stromu strán, kým ho nenapojíte do cieľového uzla /Pages a sami neopravíte /Count. Skopírovaná položka osnovy prichádza o /Prev rovnakým spôsobom a potrebuje znovu vybudovať reťaz súrodencov. To je úprimný kompromis: CopyForeignObject vám dá správne uzavretie a štruktúrne prepojenie rodiča necháva na volajúcom, čo je tá istá hranica, v ktorej pracuje nahradenie strán so zachovaním čísel objektov

Oprava v PDFlibPas CopyForeignObject pre Delphi: explicitný test InProgress beží pred vyhľadávaním v mape, cyklická spätná hrana sa stane null objektom a volajúci potom prepojí skopírovanú stranu do cieľového stromu strán
Explicitný stav in-progress nahradzuje preťažený Nil, takže spätná hrana sa rozlíši na null a volajúcemu ostane jedna štruktúrna oprava

Prečo musí byť položka mapy vyhradená pred NewObjRef

Zjavná alternatíva by obišla celý tanec s in-progress: najprv alokovať prázdny škrupinový objekt, zaregistrovať jeho skutočné číslo v mape a potom škrupinu vyplniť, keď sa sklonujú deti. Tu to nefunguje, pretože TPDFIndObj.Obj je len na čítanie a jeho obsah sa po konštrukcii nedá vymeniť — nie je čo vyplniť. Číslo a obsah rozhoduje spolu NewObjRef, čo znamená, že položka mapy musí vzniknúť pred rekurzívnym volaním a dokončiť sa po ňom, a interval medzi týmito dvoma okamihmi je presne to, čo musí InProgress pokryť. Jeden dôsledok stojí za to poznať, kým si pozriete diff výstupu: keďže NewObjRef beží po zapísaní detského uzavretia, číslovanie v cieli vychádza zdola nahor a čísla objektov nezrkadlia poradie zdroja. Formát súboru je pri tom nezáväzný, ale bajtové porovnanie s ručne postaveným očakávaním áno. Ak beh nechá objekty, ktoré ste sa rozhodli nenapojiť, sú nereferencované, nie poškodené, a mark-and-sweep zber nedosiahnuteľných PDF objektov je nástroj, ktorý ich pred uložením vyčistí

Regresia, ktorá to pokrýva, potrebuje jeden detail, ktorý prekvapí ľudí píšucich testy proti TPDFlib: konštruktor už drží predvolený dokument, takže DocumentCount začína na 1 a dvojdokumentová fixtúra musí asertovať >= 2, nie = 2. Popri úspešnej kópii test pripína tri zamietnutia — neznáme zdrojové ID, vybraný dokument ako vlastný zdroj a číslo objektu nula — všetky vracajú 0 namiesto vyvolania výnimky, pretože slučka zlučovania je zlé miesto na zistenie, že guard klauzula hádže

Kam sa to hodí v pipeline zlučovania

Kopírovanie na úrovni objektov je primitív, ktorý siahnete, keď je zlučovanie celých súborov príliš hrubé: vytiahnutie jedného fontového programu zo šablóny, potiahnutie jediného formulárového XObjectu do dokumentu na pečiatkovanie alebo presun anotácie s jej appearance streamami naprieč súbormi bez vlečenia zvyšku strany. PDFlibPas ho vystavuje ako jediné volanie proti načítaným dokumentom a ako sedí so zvyškom nízkoúrovňového API objektov, vidíte v referencii PDFlibPas Delphi PDF Library