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ě
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á
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ů
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