Odborný článok

PDFlibPas MovePage: keď zdedené boxy zdieľajú inštancie

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:

  • MovePage materializuje 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 Resources
  • SetPageBox nasledoval 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 sa MovePage na tom podieľal alebo nie
  • CopyPageRanges materializuje 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
PDFlibPas aliasing pri MovePage, kde presunutá strana aj jej bývalý sused držali inštanciu poľa MediaBox predka samotného, takže SetPageBox editoval jednu stranu a zmenil rozmery druhej; od v3.539.36 materializácia pripája dekódované kópie a úpravy ostanú lokálne na strane, ktorej sa dotknete
Dve položky stromu strán ukazujúce na jedno pole v pamäti spravili, že každá úprava pristala u každého držiteľa, a uložené PDF ostalo celý čas platné

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

PDFlibPas okružná jazda materializácie, kde PLInheritPageAttributes pichá Page.Owner.Decode(Value.Output): priame pole sa serializuje do literálneho textu a dekóduje do sviežej inštancie, zatiaľ čo nepriame 11 0 R sa serializuje a dekóduje do novej referencie, ktorá stále ukazuje na zdieľaný objekt 11
Serializácia a znovuparsanie dá PDF objektovú sémantiku zadarmo: priame hodnoty sa skopírujú, referencie ostanú referenciami, presne ako zamýšľa ISO 32000-1

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

PDFlibPas copy-on-write rozhodnutie SetPageBox: keď vlastná položka strany je priame pole, edituje sa na mieste a keď je to nepriama referencia alebo chýba, zapisovač ju nahradí novým priamym poľom, takže zdieľaný objekt 11 si ponechá hodnotu pre každú inú citujúcu stranu
Kopírovanie pri materializácii nestačí, kým referencie stále ukazujú na zdieľané objekty, takže zapisovač boxov edituje len to, čo strana vlastní
Kódová cestaPred v3.539.36Od v3.539.36
Materializácia MovePageStrana drží priame inštancie predka samotnéhoStrana drží dekódované kópie; referencie ostanú referenciami
SetPageBoxNasleduje referenciu a edituje zdieľané poleEdituje len priame pole na strane, inak zapíše nové
Zdrojová strana CopyPageRangesZdieľa boxy Pages uzla; CropBox je inštancia MediaBoxuKaždá materializovaná hodnota na zdrojovej strane je kópia
Predvolené boxy pri klonovaní zdrojov stranyCropBox, BleedBox, TrimBox a ArtBox zdieľajú jedno poleKaž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 Add do 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, BalancePageTree alebo CopyPageRanges a 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