Technický článek

Křížová kopie PDF objektů v Delphi: pády kvůli cyklům

Spojte dva PDF ručně, přesuňte jediný objekt stránky do cílového dokumentu, a kopie narazí rovnou na access violation. PDFlibPas to řeší v CopyForeignObject: hluboce zkopíruje jeden nepřímý objekt i celou jeho referenční uzávěru a cyklické zpětné odkazy jako /Parent rozřeší na null místo rekurze

Proč kopie jedné stránky mezi dokumenty padá?

Protože strom stránek PDF je strom jen tehdy, když ho čtete směrem dolů. Projděte ho tak, jak to dělá rekurzivní kopírovač, sledováním každé hodnoty v každém slovníku, a slovník stránky vám podá /Parent, který ukazuje zpět na uzel /Pages, z něhož jste přišli, a tenhle uzel podá /Kids, který ukazuje zpět na stránku. ISO 32000-1 §7.7.3 činí /Parent povinným na každém uzlu stromu stránek kromě kořene, takže to není poškozený soubor, který můžete zamítnout — je to normální tvar každého dokumentu, který vám kdy projde rukama

Druhá polovina problému je číslování. Nepřímé objekty se identifikují číslem objektu lokálním pro jeden soubor (ISO 32000-1 §7.3.10), takže objekt přetažený z dokumentu A do dokumentu B se musí přečíslovat a každý odkaz na něj uvnitř zkopírované uzávěry se musí přečíslovat stejným způsobem, jinak dva odkazy, které dřív mířily na jedno sdílené písmo, míří na dvě nesouvisející věci. Tohle přečíslování je stejná práce, kterou rychlý merge dělá na úrovni bajtů, a vyplatí se číst obě věci vedle sebe: posun referencí na úrovni bajtů pro rychlý PDF merge to řeší překladem celých souborů, zatímco kopie na úrovni objektů to musí řešit po jedné hraně

Proč si kopie PDF mezi dokumenty v Delphi žádá pozornost: slovník stránky a jeho uzel /Pages uzavírají cyklus přes /Parent a /Kids, uzávěra písma běží dolů a končí a PDFlibPas přemapuje každé číslo objektu lokální pro soubor
Strom stránek uzavírá smyčku přes /Parent a /Kids, zatímco uzávěry obsahu končí, a každé zkopírované číslo objektu se musí na cestě přemapovat

Co PDFlibPas CopyForeignObject skutečně kopíruje

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) naklonuje jeden nepřímý objekt a všechno, co z něj dosáhnete — vnořené slovníky, pole, řetězce, názvy, čísla a proudy s jejich slovníky nedotčenými — do aktuálně vybraného dokumentu a vrátí nenulový handle na novou nepřímou referenci. Zdrojová čísla objektů se přemapovávají přes živou mapu drženou po dobu volání, takže objekt dosažený dvakrát v uzávěře se naklonuje jednou a sdílí se dvakrát. Vrací nulu, bez vyhození výjimky, když je ID zdrojového dokumentu neznámé, když je zdrojem samotný vybraný dokument nebo když je ObjectNumber pod 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 vrací 1 při úspěchu
    SourceDoc := Lib.SelectedDocument;   // načtení vybralo to, co načetlo
    Lib.SelectDocument(TargetDoc);       // kopie míří 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 koušou při prvním spuštění. LoadFromFile odpovídá 1 nebo 0, ne ID dokumentu, takže handle, který potřebujete, dostanete z SelectedDocument hned po načtení; a kopie vždycky píše do toho, co SelectDocument naposledy učinilo aktuálním, nikdy ne do dokumentu, ze kterého jste načítali. Interně rekurze navíc nese tvrdý strop hloubky 64, což je pojistka proti patologickému zanoření, ne mechanismus řešící cykly — zpracování cyklů je samostatné a záměrné

Proč rezervované mapování na Nil nezastaví cyklus?

Protože Nil v mapovací tabulce znamená dvě různé věci najednou a kód je neumí rozeznat. Zjevná obrana proti cyklu je přidat záznam do mapy dřív, než se zanoříte do objektu, takže cokoli, co se vrátí zpět, záznam najde a zastaví se. Ale záznam ještě nemůže držet skutečný cíl — ten neexistuje, dokud se nenapíše uzávěra pod ním — takže drží Nil a vyhledávání, které má zpětnou hranu chytit, přečte Nil a dojde k závěru, že objekt nikdy mapovaný nebyl

// Rozbité: rezervovaný cíl Nil je nerozeznatelný od "zatím nemapováno"
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;          // rezervováno, stále Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // dosypáno až na cestě ven
end;

Projděte to stránkovou smyčkou. Klon stránky dosáhne /Parent, zanoří se do uzlu /Pages, ten dosáhne /Kids a zanoří se zpět do stránky — jejíž rezervovaný záznam stále čte Nil, takže se naklonuje podruhé, a potřetí, každá úroveň postrčí nový rámec a nový napůl postavený objekt. To, co pozorujete, ale není čisté přetečení zásobníku: vnější rámce sedí na referencích, jejichž cíle nikdy nebyly přiřazeny, takže první zápis přes jeden z těch slotů je access violation někde, co k příčině vůbec nepřipomíná

Proč rezervovaný cíl Nil nezastaví cyklus v kopii mezi dokumenty v PDFlibPas: vyhledávání nerozezná rezervovaný záznam od nemapovaného, takže kopírovač sestává přes stále hlubší napůl postavené rámce, dokud zápis nespadne
Protože cíl Nil odpovídá na dvě různé otázky najednou, zpětná hrana se nikdy nerozezná a stránka se naklonuje znovu při každém průchodu

Oprava: výslovný stav in-progress

Oprava spočívá v tom přestat přetěžovat Nil a zeptat se přímo. Záznam mapy, jehož cíl je stále nepřiřazený, znamená tento objekt se právě klonuje a predikát InProgress testuje přesně to, ještě před obyčejným vyhledáváním. Když je pravdivý, hrana je cyklus zpět k předkovi aktuálního klonu a PDFlibPas na ni vydá null objekt místo toho, aby ji sledovala

// Záznam mapy s cílem Nil značí klon rozjetý v plném proudu
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;

// ... uvnitř CloneObject, pro nepřímou referenci:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // cyklická zpětná hrana, nezanořovat
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);            // visící zdrojová reference
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // rezervovat, pak zanořit
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // dosypat
end;
Exit(NewRef);

Zobecnit to je bezpečné jen díky strukturnímu faktu o PDF: cykly v grafu objektů se objevují na zpětných odkazech, ne na obsahových hranách. /Parent ve stromu stránek a /Prev v řetězci osnovy míří nahoru nebo dozadu na něco již navštíveného; uzávěra písma, snímkového XObjectu nebo formulářového XObjectu běží dolů a končí. Takže kopie deskriptoru písma, barevného prostoru nebo slovníku stínování se nahrazením nulou nedotkne — nic v těch uzávěrách nezasáhne InProgress. Cena, řečená napřímo, je ta, že cyklická hrana kopii nepřežije. Slovník stránky klonovaný tímto způsobem dorazí s /Parent jako null objektem, což ISO 32000-1 §7.3.9 činí rovnocenným chybějící položce, takže zkopírovaná stránka je platný objekt, který nepatří žádnému stromu stránek, dokud ho nepřipojíte do cílového uzlu /Pages a sami nespravíte /Count. Zkopírovaná položka osnovy ztrácí /Prev stejným způsobem a žádá si opravu řetězce sourozenců. To je upřímný obchod: CopyForeignObject vám dá správnou uzávěru a strukturní přeparentování nechá na volajícím, což je tatáž hranice, v níž pracuje nahrazování stránek se zachováním čísel objektů

Oprava v PDFlibPas CopyForeignObject pro Delphi: výslovný test InProgress běží před vyhledáváním v mapě, cyklická zpětná hrana se stává null objektem a volající poté znovu připojí zkopírovanou stránku do cílového stromu stránek
Výslovný stav in-progress nahrazuje přetížené Nil, takže zpětná hrana se rozřeší na null a volajícímu zbyde jedna strukturní oprava

Proč se záznam mapy musí rezervovat před NewObjRef

Zjevná alternativa by celý tanec s in-progress obejla: nejdřív alokovat prázdnou skořápku objektu, zaregistrovat její skutečné číslo do mapy a pak skořápku doplnit, jakmile se naklonují děti. Tady to nefunguje, protože TPDFIndObj.Obj je jen pro čtení a jeho obsah nelze po konstrukci vyměnit — skořápka k doplnění neexistuje. Číslo a obsah rozhoduje společně NewObjRef, takže záznam mapy se musí vytvořit před rekurzivním voláním a dokončit po něm a interval mezi těmito dvěma okamžiky je přesně to, co musí InProgress pokrýt. Jeden důsledek hodný znalosti, než začnete diffovat výstup: protože NewObjRef běží poté, co se napíše uzávěra dětí, čísla v cíli vycházejí zdola nahoru a čísla objektů nebudou zrcadlit pořadí zdroje. Formát souboru je nezajímá, ale bajtové srovnání s ručně postaveným očekáváním ano. Pokud běh zanechá objekty, o kterých jste se rozhodli, že je do ničeho nepropojíte, jsou nereferencované, ne poškozené, a mark-and-sweep sběr nedosažitelných PDF objektů je nástroj, který je před uložením vyčistí

Regresní test pokrývající tohle potřebuje jeden detail, který překvapí lidi píšící testy proti TPDFlib: konstruktor už drží výchozí dokument, takže DocumentCount začíná na 1 a fixtura se dvěma dokumenty musí asertovat >= 2, ne = 2. Vedle úspěšné kopie test připíná tři zamítnutí — neznámé ID zdroje, vybraný dokument jako vlastní zdroj a číslo objektu nula — všechna vracející 0 místo vyhazování, protože smyčka merge je špatné místo zjistit, že ochranná klauzule hází výjimky

Kde se to hodí v pipeline merge

Kopírování na úrovni objektů je primitiv, po kterém sáhnete, když je merge celých souborů příliš hrubý: vyzvednout jeden program písma ze šablony, přitáhnout jediný formulářový XObject do razítkovacího dokumentu nebo přesunout anotaci s jejími proudy vzhledu napříč soubory bez vláčení zbytku stránky. PDFlibPas to vystavuje jako jediné volání nad načtenými dokumenty a jak to sedí ke zbytku nízkoúrovňového objektového API, ukazuje referenční stránka PDFlibPas Delphi PDF Library