Műszaki cikk

PDFlibPas MovePage és a megosztott örökölt dobozok

A PDFlibPasban, a Delphi PDF libraryban, az MovePage-cel mozgatott oldal eddig ugyanazokat a MediaBox-, CropBox- és Resources-objektumokat kapta, amiket a régi Pages csomópontja tárolt, így egy későbbi SetPageBox vagy DrawText a mozgatott oldalon csendben átírta azt a csomópontot, meg minden olyan testvért, ami még örökölt tőle. v3.539.36 óta a mozgatott oldal saját másolatokat kap, az indirekt hivatkozás pedig hivatkozás marad. Ugyanez a kiadás két rokon utat zár le: a SetPageBox-ot egy indirekt dobozon, amit több oldal oszt meg, meg a CopyPageRanges-t, ami a forrásdokumentum oldalait a Pages csomópontjukhoz kötötte, a CropBoxot pedig a MediaBoxhoz

Azok a hibajelentések, amik ide vezettek, soha nem szólnak objektumazonosságról. Azt írják, hogy „levágtam a 7. oldalt, és a 8-tól 12-ig terjedő oldalak is leválódtak", vagy hogy „szűkítettem a CropBoxot, és a MediaBox elment vele", vagy a legzavaróbbat: „átmásoltam egy oldalt egy új dokumentumba, és az eredeti fájl megváltozott". Semmi nem omlik össze, semmi nem szivárog, és a mentett fájl tökéletesen érvényes PDF. Csak olyan geometriát tartalmaz, amit senki nem kért

Miért méretezi át a SetPageBox egy oldal testvéreit?

A SetPageBox azért méretezte át a testvéreket, mert két oldalfa-bejegyzés ugyanarra a memóriabeli tömbre mutatott, a SetPageBox pedig helyben szerkeszti a céltömbjét. Bármelyik oldal vagy Pages csomópont, aminek a kezében volt ugyanaz a példány, látta a módosítást. Három kódút torzította össze így a PDFlibPast v3.539.36 előtt:

  • a MovePage az örökölhető attribútumokat az oldalra anyagosítja, mielőtt leválasztaná a szülőjéről, és az ős saját objektumait csatolta hozzá másolatok helyett, így a mozgatott oldal és a volt testvérei közös doboztömböt és Resources szótárt használtak
  • a SetPageBox követte az indirekt hivatkozásokat, és a hivatkozott tömböt szerkesztette, így egy fájlban, ahol több oldal mutat egyetlen /MediaBox 11 0 R objektumra, egyetlen hívás átméretezte mindet, akár volt MovePage, akár nem
  • a CopyPageRanges az örökölt értékeket a forrásoldalra anyagosítja, mielőtt klónozná a céldokumentumba, és a Pages csomópont példányait csatolta a forrásoldalhoz, a MediaBox példányát pedig alapértelmezett CropBoxként
PDFlibPas MovePage aliasing: a mozgatott oldal és a volt testvére is az ős saját MediaBox tömbpéldányát tartotta, így a SetPageBox az egyik oldalt szerkesztette, a másikat átméretezte; v3.539.36 óta az anyagosítás dekódolt másolatokat csatol, a szerkesztés pedig annál az oldalnál marad, amit megérintesz
Két oldalfa-bejegyzés ugyanarra a memóriabeli tömbre mutatott, ezért minden szerkesztés minden birtokosnál landolt, és a mentett PDF eközben végig érvényes maradt

A MovePage-eset rövid történetű. v3.539.27 előtt a MovePage csak a /Resources-t vitte át, így egy másik szülő alá mozgatott oldal csendben átvette annak méretét és forgatását. A v3.539.27 pótolta a hiányzó MediaBoxot, CropBoxot és Rotate-ot, amire a CollateDocumentsEx is épít, amikor átrendezi az oldalakat, de az ős értékeit megosztott példányként csatolta. Ezt az ablakot zárja a v3.539.36. A SetPageBox- és CopyPageRanges-utak idősebbek; v3.539.36 előtt bármelyik buildben ott vannak

Közvetlen értékek, indirekt hivatkozások és az oldalattribútumok öröklése

Egy örökölt oldalattribútum helyes másolata a közvetlen értékeket duplikálja, az indirekt hivatkozásokat pedig hivatkozásként tartja meg, mert pontosan ezt a különbséget húzza meg maga az ISO 32000-1. Egy közvetlen objektum, például a szótárba írt [0 0 400 300], csak ahhoz a szótárhoz tartozik. Az indirekt objektumot, amit egyszer definiálnak 11 0 obj-ként és 11 0 R-ként hivatkoznak, eleve megosztásra tervezték: az ISO 32000-1 §7.3.10 a fájl bármely pontjáról címezhetővé teszi, és minden 11 0 R ugyanazt az objektumot jelenti

Az oldalattribútum-öröklés, az ISO 32000-1 §7.7.3.4-e, harmadik esetet ad hozzá. A Resources, MediaBox, CropBox és Rotate ülhet Pages csomóponton, és minden olyan leszármazott oldalra érvényes, ami nem definiál sajátot. Az oldal nem tárolja az értéket; a /Parent-en keresztül keresi fel. Ez a keresési lánc akkor törik el, hogyha az oldal szülőt vált, ezért a MovePage-nek és a BalancePageTree-nek előbb az oldalra kell írnia a tényleges értékeket. A kérdés csak az, hogyan

Miért rejti el az objektumpool a hibát

A PDFlibPasban minden feldolgozott vagy létrehozott PDF objektum a dokumentum TPDFStructure pooljának a tulajdona, a szótárak és tömbök pedig egyszerű pointereket tárolnak a bejegyzéseikre. A TPDFDictionary.Add feljegyzi a pointert, és ennyi. Egy példány két szülőkonténerbe tétele ezért minden olyan szinten legális, amin a futtatókörnyezet ellenőrizhet: nincs double free a lebontásnál, nincs hivatkozásszámláló, ami elromolhatna, nincs kivétel. A szerializáció ugyanolyan elnéző: minden konténer inline írja a megosztott példány aktuális értékét, és bármilyen szerkesztés előtt a kimenet bájtról bájtra ugyanaz, amit egy helyes másolat adna

Az aliasing akkor bukik felszínre, amikor valaki helyben módosítja a megosztott példányt. A SetPageBox pontosan ezt teszi a meglévő tömb fölé húzott téglalap-wrapperen keresztül, és az oldalra rajzolás is ezt teszi a Resources szótárral, amikor fontot vagy képet regisztrálsz. A módosítás csendben megérkezik minden másik konténerbe, ami a pointert tartja

Hogyan másol a PDFlibPas v3.539.36 a megosztás helyett

A PDFlibPas v3.539.36 mindkét végén javítja a problémát: az anyagosítás mostantól másolatokat csatol, a dobozírás pedig csak olyan tömböt szerkeszt, ami az oldal tulajdona. A két javítás egy-egy olyan esetet fed le, amit a másik nem tud

Az anyagosító segítő, a PLInheritPageAttributes, mostantól Page.Owner.Decode(Value.Output)-t csatol Value helyett. A szerializátoron átvitt oda-vissza út durva, de egzakt módja annak, hogy ingyen megkapd a PDF szemantikát. Egy közvetlen tömb vagy szótár literal szövegként sorosítódik, és friss, független példánnyá dekódolódik. Az indirekt hivatkozás 11 0 R-ként sorosítódik, és új hivatkozásobjektummá dekódolódik, ami ugyanarra a 11-es objektumra mutat, így az oldal továbbra is a megosztott objektumra hivatkozik ahelyett, hogy inline másolatot kapna — ez őrzi meg a v3.539.27-ben bevezetett hivatkozás-viselkedést. A másolat pontosan olyan mély, amilyen a közvetlen struktúra: amit egy másolt szótáron belül hivatkozáson át érsz el, az megosztott marad, ahogy a fájlformátum akarja. A BalancePageTree ugyanezt a segítőt hívja minden áttelepített oldalra, így ott is külön példányt kapnak az anyagosított oldalak

PDFlibPas anyagosítás oda-vissza útja, ahol a PLInheritPageAttributes a Page.Owner.Decode(Value.Output)-t csatolja: egy közvetlen tömb literal szövegként sorosítódik, és friss példánnyá dekódolódik, egy indirekt 11 0 R pedig új hivatkozássá dekódolódik, ami továbbra is a megosztott 11-es objektumra mutat
A szerializálás és az újraparzolás ingyen adja a PDF objektumszemantikát: a közvetlen értékek másolódnak, a hivatkozások hivatkozások maradnak, pontosan ahogy az ISO 32000-1 akarja

A másolás önmagában kevés, mert a hivatkozási eset továbbra is megosztott objektumra mutat. Ha a SetPageBox követné azt a hivatkozást, és szerkesztené a 11-es objektumot, a mozgatott oldal újra átméretezné a régi szülőt és a többi gyerekét. Ezért a dobozíró mostantól copy-on-write-ot alkalmaz: csak akkor szerkeszt helyben, ha az oldal saját bejegyzése közvetlen tömb, az indirekt vagy hiányzó dobozt pedig új közvetlen tömbre cseréli. A 11-es objektum érintetlen marad minden másik oldalnak, ami hivatkozik rá

PDFlibPas SetPageBox copy-on-write döntés: ha az oldal saját bejegyzése közvetlen tömb, helyben szerkesztődik, ha viszont indirekt hivatkozás vagy hiányzik, az író új közvetlen tömbre cseréli, így a megosztott 11-es objektum megtartja az értékét minden másik hivatkozó oldalnak
Az anyagosításkori másolás addig kevés, amíg a hivatkozások megosztott objektumokra mutatnak, ezért a dobozíró csak azt szerkeszti, ami az oldal tulajdona
Kódútv3.539.36 előttv3.539.36 óta
MovePage anyagosításAz oldal az ős saját közvetlen példányait tartjaAz oldal dekódolt másolatokat tart; a hivatkozások hivatkozások maradnak
SetPageBoxKöveti a hivatkozást, és a megosztott tömböt szerkesztiCsak közvetlen tömböt szerkeszt az oldalon, egyébként újat ír
CopyPageRanges forrásoldalaMegosztja a Pages csomópont dobozait; a CropBox a MediaBox példányaMinden anyagosított érték a forrásoldalon másolat
Alapértelmezett dobozok oldalerőforrások klónozásakorA CropBox, BleedBox, TrimBox és ArtBox egy tömböt oszt megMinden alapértelmezett doboz saját tömböt kap

Az utolsó sor a lappangó. Amikor a library klónozza egy oldal erőforrásait oldalrögzítéshez vagy összefésüléshez, pótolja a hiányzó CropBox, BleedBox, TrimBox és ArtBox bejegyzéseket, és ezek korábban ugyanaz a tömbpéldány voltak. Egyik jelenlegi hívó sem engedte azt az aliast annyi ideig élni, hogy szerkesszék, de a következő hívó engedte volna. Hogy ezek az alapértelmezett dobozértékek hogyan születnek, az önálló téma, amit a PDFlibPas TrimBox, BleedBox és CropBox alapértelmezéseket taglaló útmutatója fed le

A MovePage aliasing reprodukálása kézzel épített PDF-fel

Bármelyik PDFlibPas build gyors ellenőrzése egy kis, kézzel írt PDF, amit LoadFromString-gel töltesz be, és amiben minden objektumszám előre ismert. Az alábbi segítő klasszikus kereszthivatkozási táblát ír helyesen kiszámolt bájtoffszettekkel, így a teszt nem a parser sérült fájlokra való helyreállító viselkedésére támaszkodik

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);   // a "N 0 obj" nulla alapú bájtoffszetje
    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      // minden bejegyzés pontosan 20 bájt
    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;

A tesztdokumentumnak két köztes Pages csomópontja van. A 3-as csomópont indirekt MediaBoxot hordoz (a 11-es objektum, 400-szor 300 pont), egy közvetlen CropBoxot és egy közvetlen Resources szótárt, és két oldala van. A 4-es csomópont Letter méretű MediaBoxot hordoz, és a harmadik oldal az övé. Az 1. oldal 3. pozícióba mozgatása áttelepíti a 4-es csomópont alá, ami pontosan az a mozdulat, ami anyagosítást igényel: nélküle az oldal Letter oldallá válna

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);                       // az imént mozgatott oldal
    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');

    // A régi szülőt a másik oldal kiválasztása ELŐTT nézd meg (lásd lent)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // volt 2. oldal, még mindig a 3-as csomópont alatt
    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;

A GetPageBox(BoxType, Dimension) doboztípusként az 1-et a MediaBoxra, a 2-t a CropBoxra veszi, dimenzióként a 2-t a szélességre. Az alapértelmezett bal-alsó origóval a SetPageBox(1, 0, 200, 200, 200) azt jelenti: bal 0, felső 200, 200 széles és 200 magas. A v3.539.27 és v3.539.35 közti buildeken a testvér-ellenőrzések elbuknak: a CropBox-módosítás a 3-as csomópont közvetlen tömbjébe érkezik, a MediaBox-módosítás pedig a hivatkozáson át írja át a 11-es objektumot

Átírja-e a CopyPageRanges a forrásdokumentumot?

v3.539.36 óta a CopyPageRanges továbbra is ráír a forrásoldalakra, de minden írt érték külön másolat, így a forráson tett későbbi módosítások annál az oldalnál maradnak, amit szerkesztesz. Maga az írás szándékos: a forrásoldalnak explicit MediaBoxra, CropBoxra, Rotate-ra és Resourcesra van szüksége, mielőtt a szótárát klónozzák a célba, különben a másolat elveszítené mindent, amit örökölt. Az átrenszámozást és az oldal célba másolását a objektumok mély másolása dokumentumok között PDFlibPasban cikk tárgyalja; ez a hiba a forrás oldalán ült, akit a legtöbben olvasónak hisznek a másolás szempontjából

A kimeneten soha nem látszott. Megosztva vagy másolva, az anyagosított értékek azonosan sorosítódnak, így mindkét dokumentum a javítás előtt és után bájtról bájtra ugyanúgy mentődött. Csak a forrásdokumentumnak a másolás utáni módosítása leplezte le az aliast:

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;             // ez lesz a kiválasztott dokumentum
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // csak a CropBoxot szűkítjük
    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);            // a másolat megtartja az eredeti méretét
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

v3.539.36 előtt mindkét oldal a gyökércsomópont közvetlen MediaBoxját örökölte itt, a másolat ezt a példányt a forrás 1. oldalához csatolta, majd megint, az 1. oldal CropBoxaként. A CropBox szűkítése ezért a MediaBoxot szűkítette, a MediaBox átméretezése pedig a gyökércsomóponton át a 2. oldalt méretezte át. Olyan munkafolyamatoknál jött ez elő, amik kimásolják az oldalakat, majd tovább szerkesztik a forrást, például mielőtt lenyírják az eredetieket, duplex szkenek összefésülése egyetlen PDF-be

Miért olyan nehéz tesztelni a példány-aliasingot?

A példány-aliasing azért nehéz tesztcikk, mert a megfigyelhető hatás három lépést kíván adott sorrendben: létrehozod az aliast, módosítod az egyik oldalt, majd megvizsgálod a másikat, mielőtt bármi más hozzáér. A legtöbb teszt csak az első lépést csinálja meg, és a mentett kimenetet hasonlítja össze, ami attól függetlenül azonos, hogy létezik-e az alias

A PDFlibPas sorrendi csapdája a SelectPage. Egy oldal kiválasztása újraalkalmazza az aktuális fontot a SelectFont-on át, ami regisztrálja azt a fontot az oldal erőforrásaiban. Egy olyan oldal, aminek nincs saját /Resources-a, a szülője szótárát kapja fel, így már az is legálisan /Font-ot ír a Pages csomópontra, ha csak kiválasztod. A fenti MovePage-tesztben a volt 2. oldal kiválasztása a Helvetica bejegyzést teszi a 3-as csomópontra, ami helyes viselkedés, nem szivárgás. Ezért fut a GetObjectToString(3)-ellenőrzés a SelectPage(1) előtt; cseréld fel a kettőt, és a teszt elbukik egy javított builden

Ez a szabály azt is kijelöli, amit a v3.539.36 szándékosan magára hagy. Erőforrás írása egy olyan oldalra, ami örökli a Resources szótárát, az ős szótárába ír, és minden testvér látja az új bejegyzést. Ez az öröklés úgy, ahogy specifikálva van, nem példánymegosztás, és ártalmatlan, mert font- vagy képnév hozzáadása egy megosztott szótárhoz nem változtatja meg, hogyan renderelődnek a többi oldalak. Ha azt akarod, hogy egy oldal hagyja abba az öröklést, előbb adj neki saját Resources szótárt

Ellenőrzőlista PDF objektummodell-kódhoz

A tanulságok bármelyik poolra és pointerkonténerekre épülő PDF objektummodellre általánosítanak, Delphiben vagy máshol:

  • Amikor az ISO 32000-1 §7.7.3.4 szerint anyagosítod az örökölt attribútumokat, a közvetlen értékeket mélymásold, az indirekt hivatkozásokat pedig új hivatkozásként tartsd meg ugyanarra az objektumra
  • Soha ne Add-olj meglévő példányt második konténerbe, hacsak a megosztás nem szándékos és dokumentált; a pool általi tulajdonlás azt jelenti, hogy a futtatókörnyezet soha nem fog panaszkodni
  • Csak azt szerkeszd helyben, amit az aktuális csomópont közvetlen objektumként birtokol; az indirekt vagy örökölt értékeket cseréld friss közvetlen objektumra (copy-on-write)
  • A másik bejegyzésből származtatott alapértékeknek, mint egy CropBox a MediaBoxból, saját példány kell
  • Az aliasingot módosít-aztán-megvizsgál sorozatokkal teszteld a másik birtokoson, és nézd meg azoknak a hívásoknak a sorrendjét, amik közben legálisan írhatnak
  • A mentett kimenet összevetése itt semmit nem bizonyít: a megosztott és a másolt értékek azonosan sorosítódnak az első módosításig
  • PDFlibPas alatt frissíts v3.539.36-ra vagy újabbra, ha hívsz MovePage-et, CollateDocumentsEx-et, BalancePageTree-t vagy CopyPageRanges-t, majd szerkeszted az oldaldobozokat vagy rajzolsz az oldalakra

A PDFlibPas az oldalfaszerkesztést, a dokumentumok közti másolást és az oldaldoboz-vezérlést egyetlen TPDFlib osztályon át adja Delphihez, C++Builderhez és Free Pascalhoz. Kiadásokért, platformokért és a teljes API referenciáért lásd a PDFlibPas Delphi PDF library termékoldalát