Stránka přesunutá přes MovePage dostávala v PDFlibPas, PDF knihovně pro Delphi, úplně tytéž objekty MediaBox, CropBox a Resources, které držel její starý Pages uzel, takže pozdější SetPageBox nebo DrawText na přesunuté stránce potichu přepsal ten uzel a každého sourozence, který z něj stále dědil. Od v3.539.36 dostává přesunutá stránka vlastní kopie a nepřímá reference zůstává referencí. Tamtéž vydání zavírá dvě související cesty: SetPageBox na nepřímém boxu sdíleném několika stránkami a CopyPageRanges nechávající stránky zdrojového dokumentu svázané s jejich Pages uzlem, přičemž CropBox byl svázaný s MediaBoxem
Reporty, které vedou sem, nikdy nezmíní identitu objektů. Říkají věci jako „ořízl jsem stránku 7 a stránky 8 až 12 se ořezaly taky“, nebo „zúžil jsem CropBox a MediaBox se s ním posunul“, nebo ten nejzmatenější: „zkopíroval jsem stránku do nového dokumentu a původní soubor se změnil“. Nic nespadne, nic neteče a uložený soubor je dokonale validní PDF. Prostě obsahuje geometrii, o kterou nikdo nestál
Proč SetPageBox na jedné stránce mění velikost jejích sourozenců?
SetPageBox měnil velikost sourozenců, protože dvě položky page tree ukazovaly na jedno pole v paměti a SetPageBox edituje svůj cílový array na místě. Každá stránka nebo Pages uzel držící tutéž instanci editaci viděl. Tři kódové cesty v PDFlibPas tohle sdílení před v3.539.36 vyráběly:
MovePagematerializuje děditelné atributy na stránku, než ji odpojí od rodiče, a připojoval vlastní objekty předka místo kopií, takže přesunutá stránka a její bývalí sourozenci sdíleli box array a Resources slovníkSetPageBoxnásledoval nepřímé reference a editoval referencovaný array, takže soubor, v němž několik stránek ukazuje na jeden objekt/MediaBox 11 0 R, měl všechny ty stránky změněné jedním voláním, ať bylMovePagezapletený nebo neCopyPageRangesmaterializuje zděděné hodnoty na zdrojové stránce, než ji naklonuje do cílového dokumentu, a připojoval instance Pages uzlu ke zdrojové stránce, plus samotnou instanci MediaBoxu jako výchozí CropBox
Případ MovePage má krátkou historii. Před v3.539.27 přenášel MovePage jen /Resources, takže stránka přesunutá pod jiným rodičem si potichu vzala jeho velikost a rotaci. v3.539.27 opravil chybějící MediaBox, CropBox a Rotate, na což se také spolehá CollateDocumentsEx, když přeuspořádává stránky, ale připojil hodnoty předka jako sdílené instance. To je to okno, které v3.539.36 zavírá. Cesty SetPageBox a CopyPageRanges jsou starší; jakékoli sestavení před v3.539.36 je má
Přímé hodnoty, nepřímé reference a dědičnost atributů stránky
Správná kopie zděděného atributu stránky duplikuje přímé hodnoty a nechává nepřímé reference jako reference, protože právě tenhle rozdíl si klade ISO 32000-1. Přímý objekt jako [0 0 400 300] zapsaný uvnitř slovníku patří jen tomu slovníku. Nepřímý objekt, definovaný jednou jako 11 0 obj a citovaný jako 11 0 R, je sdílený podle designu: ISO 32000-1 §7.3.10 z něj dělá adresovatelný odkudkoli v souboru a každé 11 0 R znamená tentýž objekt
Dědičnost atributů stránky, ISO 32000-1 §7.7.3.4, přidává třetí případ. Resources, MediaBox, CropBox a Rotate mohou sedět na Pages uzlu a platit pro každou potomní stránku, která si nedefinuje vlastní. Stránka hodnotu nedrží; vyhledává ji přes /Parent. Tenhle vyhledávací řetěz se zlomí v okamžiku, kdy stránka změní rodiče, a proto musí MovePage a BalancePageTree nejdřív zapsat efektivní hodnoty přímo na stránku. Otázkou už jen je, jak je zapsat
Proč object pool chybu skrývá
V PDFlibPas vlastní každý naparsovaný nebo vytvořený PDF objekt pool TPDFStructure dokumentu a slovníky a arraye ukládají prosté ukazatele na své položky. TPDFDictionary.Add si zapíše ukazatel a nic víc. Přidání jedné instance do dvou rodičovských kontejnerů je proto legální na každé úrovni, kterou runtime dokáže zkontrolovat: žádné double free při rozebrání, žádný reference count, který by se mohl pokazit, žádná výjimka. Serializace je stejně shovívavá, protože každý kontejner zapisuje aktuální hodnotu sdílené instance inline, a před jakoukoli editací je výstup bajt od bajtu to, co by vyprodukovala správná kopie
Aliasing vylézá na povrch, až když někdo mutuje sdílenou instanci na místě. SetPageBox dělá přesně tohle přes rectangle wrapper nad existujícím arrayem a kreslení na stránce to dělá se slovníkem Resources, když se registruje písmo nebo obrázek. Editace dopadne, potichu, do každého dalšího kontejneru držícího ukazatel
Jak PDFlibPas v3.539.36 kopíruje místo sdílení
PDFlibPas v3.539.36 opravuje problém na obou koncích: materializace teď připojuje kopie a zápis boxů edituje jen array, který stránka vlastní. Každá oprava pokrývá případ, který ta druhá pokrýt nedokáže
Pomocník materializace, PLInheritPageAttributes, připojuje teď Page.Owner.Decode(Value.Output) místo Value. Putování tam a zpátky přes serializér je drsný, ale přesný způsob, jak dostat PDF sémantiku zadarmo. Přímý array nebo slovník se serializuje do svého literálního textu a dekóduje do čerstvé, nezávislé instance. Nepřímá reference se serializuje na 11 0 R a dekóduje do nového referenčního objektu ukazujícího na tentýž objekt 11, takže stránka se pořád odkazuje na sdílený objekt místo příjmu inline kopie, což zachovává referenční chování zavedené ve v3.539.27. Kopie je přesně tak hluboká, jak je hluboká přímá struktura: cokoli dosažitelné přes referenci uvnitř kopírovaného slovníku zůstává sdílené, jak souborový formát zamýšlí. BalancePageTree volá totéž pomocníka pro každou stránku, kterou přesazuje k jinému rodiči, takže i tam materializované stránky dostávají oddělené instance
Samotné kopírování nestačí, protože referenční případ stále ukazuje na sdílený objekt. Kdyby SetPageBox následoval tuhle referenci a editoval objekt 11, přesunutá stránka by zase změnila velikost starého rodiče a jeho dalších dětí. Zapisovač boxů proto teď aplikuje copy-on-write: edituje na místě jen tehdy, když je vlastní položka stránky přímý array, a nepřímý nebo chybějící box nahrazuje novým přímým arrayem. Objekt 11 zůstává nedotčený pro každou jinou stránku, která ho cituje
| Kódová cesta | Před v3.539.36 | Od v3.539.36 |
|---|---|---|
MovePage materializace | Stránka drží vlastní přímé instance předka | Stránka drží dekódované kopie; reference zůstávají referencemi |
SetPageBox | Následuje referenci a edituje sdílený array | Edituje jen přímý array na stránce, jinak zapíše nový |
Zdrojová stránka CopyPageRanges | Sdílí boxy Pages uzlu; CropBox je instance MediaBoxu | Každá materializovaná hodnota na zdrojové stránce je kopie |
| Výchozí boxy při klonování zdrojů stránky | CropBox, BleedBox, TrimBox a ArtBox sdílejí jeden array | Každý výchozí box dostává vlastní array |
Poslední řádek je ten latentní. Když knihovna klonuje zdroje stránky pro zachycení stránky nebo slučování, doplní chybějící položky CropBox, BleedBox, TrimBox a ArtBox a ty bývaly tutéž instanci array. Žádný současný volající nenechal ten alias žít dost dlouho na to, aby byl editovaný, ale další volající by ho nechal. Jak se ty výchozí hodnoty boxů volí, je samostatné téma, pokryté v průvodci PDFlibPas po výchozích hodnotách TrimBox, BleedBox a CropBox
Reprodukce aliasingu MovePage ručně postaveným PDF
Nejrychlejší cesta, jak vyzkoušet jakékoli sestavení PDFlibPas, je malé ručně psané PDF načtené přes LoadFromString, kde je číslo každého objektu známé předem. Pomocník níže píše klasickou cross-reference tabulku s korektně spočítanými bajtovými offsety, takže test se nespoléhá na recovery chování parseru pro poškozené soubory
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // bajtový offset "N 0 obj" od nuly
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // každá položka má přesně 20 bajtů
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
Testovací dokument má dva prostřední Pages uzly. Uzel 3 nese nepřímý MediaBox (objekt 11, 400 krát 300 bodů), přímý CropBox a přímý Resources slovník a vlastní dvě stránky. Uzel 4 má MediaBox ve velikosti Letter a vlastní třetí stránku. Přesun stránky 1 na pozici 3 ji přesazuje pod uzel 4, což je přesně ten přesun, který potřebuje materializaci: bez ní by se ze stránky stala Letter stránka
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // stránka, kterou jsme právě přesunuli
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// Prohlédněte starého rodiče PŘED výběrem jiné stránky (viz níže)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // bývalá stránka 2, stále pod uzlem 3
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension) bere typ boxu 1 pro MediaBox a 2 pro CropBox a dimenzi 2 pro šířku. S výchozím počátkem vlevo dole znamená SetPageBox(1, 0, 200, 200, 200) vlevo 0, nahoře 200, 200 široký a 200 vysoký. Na sestaveních mezi v3.539.27 a v3.539.35 kontrolky sourozenců selžou: editace CropBoxu dopadne do přímého array uzlu 3 a editace MediaBoxu přepíše objekt 11 přes referenci
Mění CopyPageRanges zdrojový dokument?
Od v3.539.36 CopyPageRanges stále zapisuje na zdrojové stránky, ale každá hodnota, kterou zapíše, je oddělená kopie, takže pozdější editace na zdroji zůstávají lokální na stránce, kterou editujete. Samotný zápis je záměrný: zdrojová stránka potřebuje explicitní MediaBox, CropBox, Rotate a Resources, než se její slovník naklonuje do cíle, jinak by kopie ztratila všechno, co zdědila. Přeenčíslování a kopírování stránky do cíle pokrývá deep copy objektů napříč dokumenty v PDFlibPas; tenhle bug seděl na zdrojové straně, o níž většina lidí předpokládá, že ji kopie jen čte
Výstup to nikdy neukázal. Sdílené nebo kopírované, materializované hodnoty se serializují identicky, takže oba dokumenty se ukládaly bajt od bajtu stejně před i po opravě. Aliasing odhalila až editace zdrojového dokumentu po kopii:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // stane se vybraným dokumentem
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // zúží jen CropBox
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // kopie si drží svou původní velikost
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Před v3.539.36 obě stránky tady dědily přímý MediaBox kořenového uzlu, kopie připojila tu instanci ke zdrojové stránce 1 a připojila ji znovu jako CropBox stránky 1. Zúžení CropBoxu tedy zúžilo MediaBox a změna velikosti MediaBoxu změnila velikost stránky 2 přes kořenový uzel. Workflow, která stránky vykopírují a pak dál editují zdroj, jako je slučování duplex skenů do jednoho PDF před ořezem originálů, jsou tím místem, kde se to ukázalo
Proč je aliasing instancí tak těžké testovat?
Aliasing instancí je těžké testovat, protože pozorovatelný efekt potřebuje tři kroky v konkrétním pořadí: vytvořit alias, mutovat jednu stranu a pak prohlédnout druhou stranu, než se jí čehokoli jiného dotkne. Většina testů udělá jen první krok a porovnává uložený výstup, který je identický, ať alias existuje nebo ne
Past na pořadí v PDFlibPas je SelectPage. Výběr stránky znovu aplikuje aktuální písmo přes SelectFont, které to písmo zaregistruje do zdrojů stránky. Stránka bez vlastních /Resources se rozliší na slovník rodiče, takže pouhé vybrání takové stránky legitimně přidá /Font do Pages uzlu. V MovePage testu výš přidá vybrání bývalé stránky 2 položku Helvetica do uzlu 3, což je korektní chování a ne leak. Proto kontrola GetObjectToString(3) běží před SelectPage(1); vyměňte je a test selže na opraveném sestavení
Tohle pravidlo zároveň označuje, co v3.539.36 záměrně nechává být. Zápis zdroje na stránku, která dědí svůj Resources slovník, zapíše do slovníku předka a každý sourozenec novou položku vidí. To je dědičnost fungující podle specifikace, ne sdílení instancí, a je to neškodné, protože přidání názvu písma nebo obrázku do sdíleného slovníku nemění, jak se ostatní stránky vykreslují. Pokud potřebujete, aby stránka přestala dědit, dejte jí nejdřív vlastní Resources slovník
Kontrolní seznam pro kód PDF object modelu
Lekce se zobecňují na jakýkoli PDF object model postavený na poolu a ukazatelových kontejnerech, v Delphi nebo jinde:
- Při materializaci zděděných atributů podle ISO 32000-1 §7.7.3.4 kopírujte přímé hodnoty do hloubky a nechávejte nepřímé reference jako nové reference na tentýž objekt
- Nikdy nepřidávejte existující instanci
Adddo druhého kontejneru, pokud to sdílení není záměrné a zdokumentované; vlastnictví poolem znamená, že runtime se nikdy neozve - Editujte na místě jen to, co aktuální uzel vlastní jako přímý objekt; nepřímé nebo zděděné hodnoty nahrazujte čerstvým přímým objektem (copy-on-write)
- Výchozí hodnoty odvozené z jiné položky, jako CropBox z MediaBoxu, potřebují vlastní instanci
- Testujte aliasing sekvencemi mutuj-pak-prohlédni na druhém držiteli a kontrolujte pořadí volání, která mohou mezitím legitimně zapisovat
- Porovnávání uloženého výstupu tady nic nedokazuje: sdílené i kopírované hodnoty se serializují identicky, dokud nepřijde první editace
- V PDFlibPas přejděte na v3.539.36 a novější, pokud voláte
MovePage,CollateDocumentsEx,BalancePageTreeneboCopyPageRangesa pak editujete page boxy nebo kreslíte na stránky
PDFlibPas vystavuje editaci page tree, kopírování napříč dokumenty a kontrolu page boxů přes jednu třídu TPDFlib pro Delphi, C++Builder a Free Pascal. Edice, platformy a kompletní API referenci najdete na produktové stránce PDFlibPas Delphi PDF library