V PDFlibPas, Delphi PDF knižnici, dostávala strana presunutá cez MovePage úplne tie isté objekty MediaBox, CropBox a Resources, ktoré držal jej starý Pages uzol, takže neskorší SetPageBox alebo DrawText na presunutej strane potichu prepísal ten uzol a každého suseda, ktorý sa z neho stále dedil. Od v3.539.36 dostáva presunutá strana vlastné kópie a nepriama referencia ostanú referenciou. Ten istý release zatvára dve súvisiace cesty: SetPageBox na nepriamom boxe, ktorý zdieľa viacero strán, a CopyPageRanges ponechávajúce strany zdrojového dokumentu viazané na ich Pages uzol, s CropBoxom viazaným na MediaBox
Reporty, ktoré vedú sem, nikdy nespomínajú identitu objektov. Hovoria veci ako „orezal som stranu 7 a strany 8 až 12 sa orezali tiež“, alebo „zúžil som CropBox a MediaBox sa pohnul s ním“, alebo tá najmenej zrozumiteľná: „skopíroval som stranu do nového dokumentu a zmenil sa pôvodný súbor“. Nič nespadne, nič nepretečie a uložený súbor je úplne platné PDF. Jednoducho obsahuje geometriu, o ktorú nikto nežiadal
Prečo SetPageBox na jednej strane zmení rozmery jej susedov?
SetPageBox menil susedov preto, lebo dve položky stromu strán ukazovali na jedno pole v pamäti a SetPageBox edituje svoj cieľový priamo na mieste. Každá strana alebo Pages uzol držiaci tú istú inštanciu videl úpravu. Tri kódové cesty v PDFlibPas toto zdieľanie pred v3.539.36 vyrábali:
MovePagematerializuje dediteľné atribúty na stranu skôr, než ju odpojí od rodiča, a pripol objekty predka samotné namiesto kópií, takže presunutá strana a jej bývalí susedia zdieľali pole boxu aj slovník ResourcesSetPageBoxnasledoval nepriame referencie a editoval referencované pole, takže súbor, v ktorom viacero strán ukazuje na jeden objekt/MediaBox 11 0 R, mal všetky tieto strany zmenené jedným volaním, či saMovePagena tom podieľal alebo nieCopyPageRangesmaterializuje zdedené hodnoty na zdrojovú stranu skôr, než ju naklonuje do cieľového dokumentu, a pripol na zdrojovú stranu inštancie Pages uzla plus samotnú inštanciu MediaBoxu ako predvolený CropBox
Prípad MovePage má krátku históriu. Pred v3.539.27 prenášal MovePage cez hranicu len /Resources, takže strana presunutá pod iným rodičom potichu prevzala jeho veľkosť a rotáciu. v3.539.27 opravil chýbajúci MediaBox, CropBox a Rotate, na čo sa tiež spolieha CollateDocumentsEx, keď preskupuje strany, ale pripchol hodnoty predka ako zdieľané inštancie. To je okno, ktoré v3.539.36 zatvára. Cesty SetPageBox a CopyPageRanges sú staršie; každé zostavenie pred v3.539.36 ich má
Priame hodnoty, nepriame referencie a dedenie atribútov strán
Korektná kópia zdedeného atribútu strany duplikuje priame hodnoty a necháva nepriame referencie referenciami, lebo presne ten rozdiel kreslí ISO 32000-1 samo. Priamy objekt ako [0 0 400 300] zapísaný vo vnútri slovníka patrí tomuto slovníku sám. Nepriamy objekt, definovaný raz ako 11 0 obj a citovaný ako 11 0 R, je zdieľaný zámerom: ISO 32000-1 §7.3.10 z neho robí adresovateľný odkiaľkoľvek v súbore a každé 11 0 R znamená ten istý objekt
Dedenie atribútov strán, ISO 32000-1 §7.7.3.4, pridáva tretí prípad. Resources, MediaBox, CropBox a Rotate môžu sedieť na Pages uzle a platiť pre každú potomka, ktorý si nedefinuje vlastný. Strana hodnotu nedrží; hľadá ju cez /Parent. Ten reťazec vyhľadávania sa láme v momente, keď strana zmení rodiča, prečo MovePage a BalancePageTree musia najprv zapísať efektívne hodnoty na stranu samotnú. Jediná otázka je ako
Prečo objektový pool skrýva omyl
V PDFlibPas je každý parsnutý alebo vytvorený PDF objekt vlastníctvom poolu TPDFStructure dokumentu a slovníky aj polia ukladajú holé pointery na svoje položky. TPDFDictionary.Add zaznamená pointer a nič viac. Pridanie jednej inštancie do dvoch rodičovských kontajnerov je preto legálne na každej úrovni, ktorú runtime vie skontrolovať: žiadne double free pri rozoberaní, žiadny referenčný počet, ktorý sa môže pokaziť, žiadna výnimka. Serializácia je rovnako zhovievavá, keďže každý kontajner zapisuje aktuálnu hodnotu zdieľanej inštancie inline a pred prvou úpravou je výstup bajt za bajtom to, čo by vyprodukovala korektná kópia
Aliasing sa ukáže až vtedy, keď niekto mutuje zdieľanú inštanciu priamo na mieste. SetPageBox robí presne to cez obálku obdĺžnika nad existujúcim poľom a kreslenie na strane to robí slovníku Resources pri registrácii fontu alebo obrázka. Úprava pristne, potichu, v každom inom kontajneri držiacom pointer
Ako PDFlibPas v3.539.36 kopíruje namiesto zdieľania
PDFlibPas v3.539.36 opravuje problém na oboch koncoch: materializácia teraz pripája kópie a zápisy boxu teraz editujú len pole, ktoré strana vlastní. Každá oprava pokrýva prípad, ktorý tá druhá nevie
Pomocník materializácie, PLInheritPageAttributes, teraz pripája Page.Owner.Decode(Value.Output) namiesto Value. Okružná jazda cez serializátor je tupý, ale presný spôsob, ako získať PDF sémantiku zadarmo. Priame pole alebo slovník sa serializuje do svojho literálneho textu a dekóduje do sviežej, nezávislej inštancie. Nepriama referencia sa serializuje na 11 0 R a dekóduje do nového referenčného objektu ukazujúceho na ten istý objekt 11, takže strana sa naďalej odvoláva na zdieľaný objekt namiesto prijatia vnorenej kópie, čo zachováva referenčné správanie zavedené vo v3.539.27. Kópia je presne taká hlboká, ako je priama štruktúra: čokoľvek dosiahnuteľné referenciou vnútri kopírovaného slovníka ostanú zdieľané, ako to formát súboru zamýšľa. BalancePageTree volá ten istý pomocník pre každú stranu, ktorú prevesí pod nového rodiča, takže aj tam materializované strany dostanú samostatné inštancie
Sama kópia nestačí, lebo referenčný prípad stále ukazuje na zdieľaný objekt. Keby SetPageBox nasledoval tú referenciu a editoval objekt 11, presunutá strana by znova zmenila rozmery starému rodičovi a jeho ostatným deťom. Zapisovač boxov preto teraz aplikuje copy-on-write: na mieste edituje len vtedy, keď vlastná položka strany je priame pole, a nepriamy alebo chýbajúci box nahradí novým priamym poľom. Objekt 11 ostane nedotknutý pre každú inú stranu, ktorá ho cituje
| Kódová cesta | Pred v3.539.36 | Od v3.539.36 |
|---|---|---|
Materializácia MovePage | Strana drží priame inštancie predka samotného | Strana drží dekódované kópie; referencie ostanú referenciami |
SetPageBox | Nasleduje referenciu a edituje zdieľané pole | Edituje len priame pole na strane, inak zapíše nové |
Zdrojová strana CopyPageRanges | Zdieľa boxy Pages uzla; CropBox je inštancia MediaBoxu | Každá materializovaná hodnota na zdrojovej strane je kópia |
| Predvolené boxy pri klonovaní zdrojov strany | CropBox, BleedBox, TrimBox a ArtBox zdieľajú jedno pole | Každý predvolený box dostane vlastné pole |
Posledný riadok je ten latentný. Keď knižnica klonuje zdroje strany pre zachytenie strany alebo zlučovanie, doplní chýbajúce položky CropBox, BleedBox, TrimBox a ArtBox a tie bývali tou istou inštanciou poľa. Žiadny súčasný volajúci nenechal ten alias žiť dostatočne dlho na to, aby bol editovaný, ale ďalší volajúci by to urobil. Ako sa tie predvolené hodnoty boxov vyberajú, je vlastná téma, pokrytá v sprievodcovi PDFlibPas po predvolených hodnotách TrimBox, BleedBox a CropBox
Reprodukcia aliasingu MovePage ručne postaveným PDF
Najrýchlejší spôsob, ako prevrieť ľubovoľné zostavenie PDFlibPas, je malé ručne písané PDF načítané cez LoadFromString, kde je každé číslo objektu známe vopred. Pomocník nižšie zapíše klasickú krížovú referenčnú tabuľku s korektne vypočítanými bajtovými ofsetmi, takže test nespolieha na správanie parsera pri poškodených súboroch
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ý ofset "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á presne 20 bajtov
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 medziľahlé Pages uzly. Uzol 3 nesie nepriamy MediaBox (objekt 11, 400 krát 300 bodov), priamy CropBox a priamy slovník Resources a vlastní dve strany. Uzol 4 má MediaBox vo veľkosti Letter a vlastní tretiu stranu. Presun strany 1 na pozíciu 3 ju prevesí pod uzol 4, čo je presne ten presun, ktorý potrebuje materializáciu: bez nej by sa strana zmenila na Letter stranu
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); // strana, ktorú sme práve presunuli
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');
// Starého rodiča prezrite PRED výberom inej strany (pozri nižšie)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // bývalá strana 2, stále pod uzlom 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) berie typ boxu 1 pre MediaBox a 2 pre CropBox a dimenziu 2 pre šírku. S predvoleným počiatkom v ľavom dolnom rohu znamená SetPageBox(1, 0, 200, 200, 200) ľavý 0, horný 200, 200 široký a 200 vysoký. Na zostaveniach medzi v3.539.27 a v3.539.35 kontrola susedov zlyhá: úprava CropBoxu pristane v priamom poli uzla 3 a úprava MediaBoxu prepíše objekt 11 cez referenciu
Má CopyPageRanges vplyv na zdrojový dokument?
Od v3.539.36 CopyPageRanges aj naďalej zapisuje na zdrojové strany, ale každá hodnota, ktorú zapíše, je samostatná kópia, takže neskoršie úpravy zdroja ostanú lokálne na strane, ktorú editujete. Samotný zápis je zámerný: zdrojová strana potrebuje explicitný MediaBox, CropBox, Rotate a Resources skôr, než sa jej slovník naklonuje do cieľa, inak by kópia stratila všetko, čo zdedila. Premenovanie a kopírovanie strany do cieľa pokrýva krížovo-dokumentová hlboká kópia objektov v PDFlibPas; tento bug sedel na zdrojovej strane, o ktorej väčšina ľudí predpokladá, že ju kópia len číta
Výstup to nikdy neukázal. Zdieľané alebo kopírované, materializované hodnoty sa serializujú identicky, takže obidva dokumenty sa uložili bajt za bajtom rovnako pred aj po oprave. Až úprava zdrojového dokumentu po kópii odhalila alias:
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 sa vybraným dokumentom
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // zúži len 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); // kópia si ponechá pôvodnú veľkosť
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Pred v3.539.36 obidve strany tu dedili priamy MediaBox koreňového uzla, kópia pripojila tú inštanciu na zdrojovú stranu 1 a pripojila ju znova ako CropBox strany 1. Zúženie CropBoxu teda zúžilo MediaBox a zmena rozmerov MediaBoxu zmenila stranu 2 cez koreňový uzol. Pracovné postupy, ktoré kopírujú strany von a potom naďalej editujú zdroj, ako kollátovanie duplex skenov do jedného PDF pred orezaním originálov, sú miesto, kde sa to ukázalo
Prečo sa aliasing inštancií tak ťažko testuje?
Aliasing inštancií sa ťažko testuje, lebo pozorovateľný efekt potrebuje tri kroky v konkrétnom poradí: vytvoriť alias, mutovať jednu stranu a potom skúmať druhú stranu skôr, než čokoľvek iné sa jej dotkne. Väčšina testov urobí len prvý krok a porovná uložený výstup, ktorý je identický bez ohľadu na to, či alias existuje
Poradová pasť v PDFlibPas je SelectPage. Výber strany znovu aplikuje aktuálny font cez SelectFont, čo ten font zaregistruje v zdrojoch strany. Strana bez vlastného /Resources sa vyrieši na slovník rodiča, takže samotný výber takejto strany legálne pridá /Font do Pages uzla. V teste MovePage vyššie pridá výber bývalej strany 2 položku Helvetica do uzla 3, čo je korektné správanie a nie únik. Preto kontrola GetObjectToString(3) beží pred SelectPage(1); vymeňte tie dva a test zlyhá na opravenom zostavení
Toto pravidlo zároveň označuje, čo v3.539.36 úmyselne necháva pokoja. Zápis zdroja na stranu, ktorá dedí svoj slovník Resources, zapisuje do slovníka predka a každý sused vidí novú položku. To je dedenie fungujúce podľa špecifikácie, nie zdieľanie inštancií, a je to neškodné, lebo pridanie mena fontu alebo obrázka do zdieľaného slovníka nemení to, ako iné strany renderujú. Ak potrebujete, aby strana prestala dediť, dajte jej najprv vlastný slovník Resources
Checklist pre kód PDF objektového modelu
Lekcie sa zobecňujú na akýkoľvek PDF objektový model postavený na poole a pointerových kontajneroch, v Delphi alebo hocikde inde:
- Pri materializácii zdedených atribútov podľa ISO 32000-1 §7.7.3.4 hlboko kopírujte priame hodnoty a nechávajte nepriame referencie ako nové referencie na ten istý objekt
- Nikdy nepridávajte existujúcu inštanciu cez
Adddo druhého kontajnera, pokiaľ to zdieľanie nie je zamýšľané a zdokumentované; vlastníctvo poolom znamená, že runtime si nikdy nestiažne - Na mieste editujte len to, čo aktuálny uzol vlastní ako priamy objekt; nepriame alebo zdedené hodnoty nahrádzajte sviežim priamym objektom (copy-on-write)
- Predvolené hodnoty odvodené od inej položky, ako CropBox od MediaBoxu, potrebujú vlastnú inštanciu
- Aliasing testujte sekvenciami mutuj-potom-skúmaj na druhom držiteľovi a kontrolujte poradie volaní, ktoré môžu medzitým legálne zapisovať
- Porovnávanie uloženého výstupu tu nič nedokáže: zdieľané aj kopírované hodnoty sa serializujú identicky až do prvej úpravy
- Na PDFlibPas upgradujte na v3.539.36 alebo novšiu, ak voláte
MovePage,CollateDocumentsEx,BalancePageTreealeboCopyPageRangesa potom editujete page boxy alebo kreslíte na strany
PDFlibPas vystavuje editovanie stromu strán, krížovo-dokumentové kopírovanie a kontrolu page boxov cez jedinú triedu TPDFlib pre Delphi, C++Builder a Free Pascal. Pozrite produktskú stránku PDFlibPas Delphi PDF library pre edície, platformy a kompletnú API referenciu