Műszaki cikk

Ritka lusta PDF-objektumindex Delphiben PDFiumPasszal

Egy 2 GB-os PDF-ből egyetlen szótár kell, és az eszköz előbb az egész kereszthivatkozási táblát kibontja a trailer /Size-a szerint méretezett tömbbe. A PDFiumPas ezt a lépést ritka lusta objektumindexre cseréli: csak az xref szakaszleírókat tartja meg, egyetlen objektumszámot igény szerint old fel korlátos ablakokon keresztül, és csak azokat a bejegyzéseket gyorsítótárazza, amelyeket ténylegesen megérintett

A kód régi alakja a FPdfCompress-ben becsületes, de drága volt. Az ApplyDefaultOpenAction a teljes fájlt egyetlen TBytes-ba olvasta, majd sűrű TPdfActiveXrefEntries tömböt osztott ki, objektumszámonként egy hellyel a /Size-ig. Két dolog romlott el nagy léptékben. Az olvasási költség lineárisan nőtt a dokumentum méretével, akkor is, amikor a hívó négy szótárt akart, és a sűrű tömb ütközött az elemző költségvetésével: a TPdfParserResourceBudget.Default a MaxObjects-t 4 000 000-ra állítja, így egy tökéletesen érvényes fájl, amelynek legmagasabb objektumszáma e fölött ül, memória-érvvel, nem helyességi érvvel utasítódott el

A PDFiumPas ritka lusta objektumindexe Delphiben összehasonlítva egy sűrű kereszthivatkozási tömbbel: a sűrű út beolvassa az egész fájlt és objektumszámonként egy helyet oszt ki a trailer méretéig, míg a ritka út csak szakaszleírókat tart meg
Csak a leírók maradnak a memóriában, a bejegyzések a fájlban maradnak, és minden olvasás korlátos egy mebibájtos ablakon megy keresztül

Miért nem válaszol erre a kérdésre a PDFium nyilvános API-ja?

Mert az információ a PDFium belsejében létezik, de sosem lépi át a C határt. A CPDF_Parser belsőleg karbantartja a kereszthivatkozási táblát, az objektumstream-tagságot és a revíziós elsőbbséget, mégis a közzétett fejlécek nem tárnak fel olyan belépési pontot, amely átvesz egy objektumszámot, és visszaadja annak nyers eltolását, generációját, hogy melyik revízió nyert, vagy melyik ObjStm-ben él. A mentési oldal ugyanilyen zárt: az FPDF_SaveAsCopy és az FPDF_SaveWithVersion csak szekvenciális írási visszahívást ad a kezébe. Egy natív mentés utáni, bájtszintű katalógusjavítás ezért a Pascal rétegben épül, ezért elemzi maga a PDFiumPas ezeket a struktúrákat a DLL újrafelhasználása helyett

Mit tart valójában a memóriában a ritka index?

Leírókat, nem bejegyzéseket. Klasszikus táblánál (ISO 32000-1 §7.5.4) egy TPdfSparseXrefSubsection tárolja az első objektumszámot, az objektumok számát, azt a bájtoffszetet, ahol a bejegyzéssorok kezdődnek, és a mért bejegyzésszélességet. Maguk a bejegyzések a fájlban maradnak. A szélesség az első sorból mérődik, nem feltételezett 20 bájt, mert a gyártók eltérnek a sorvégekben; a PDFiumPas 18-tól 64-ig fogad el, és mindent elutasít ebből a sávból kifelé, akárcsak minden olyan alszakaszt, amelynek deklarált száma túlfutna a stream végén. Kereszthivatkozási streamnél (§7.5.8) a szakasz tartja a három /W mezőszélességet, mindegyiket 0 és 8 közé szorítva, a lapított /Index párokat és a dekódolt bejegyzésbájtokat, amelyek várt hosszát /W-ből és /Index-ből számolják, mielőtt egyetlen bájt felfújódna

Az egész indexet az Initialize építi fel legfeljebb 1 MiB-os farokablakból, amely a startxref megtalálásának helye, és minden ezt követő objektumolvasás 1 MiB-os objektumablakot használ. A nyers stream plafonja 64 MiB, és egyetlen xref sor nem haladhatja meg az 1024 bájtot. Ha elolvasta a objektum- és kereszthivatkozási streamek validálása PDFiumPasszal című jegyzetünket, ugyanaz a mezőszélességi fegyelem érvényes itt is, csak most egyetlen bejegyzés címzésére használják, nem egy egész tábla auditálására

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { csak a startxref-et, a /Prev láncot és a katalógust járja }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Hogyan ér el egy keresés egy objektumot?

Számítással, mindkét elrendezésben. Egy klasszikus alszakasz rögzített szélességű sorokból áll, így egy bejegyzés címe az alszakasz kezdete plusz az objektumoffszet szorozva a mért szélességgel; a PDFiumPas ezután beolvassa azt az egy sort, elemzi a tízjegyű ofszetet és az ötjegyű generációt, a generációt a §7.5.4 szerinti 65535-es plafonhoz ellenőrzi, és a záró kulcsszót axkDirect-ként vagy axkFree-ként osztályozza. Egy kereszthivatkozási stream egy plusz lépést igényel, mert a /Index alszakaszok a dekódolt bájtfutamban összefűződnek, így az index a megelőző alszakaszok számait halmozza, mielőtt az összegzett /W szélességgel szorozna. Az 1-es típus ofszetet ad, a 2-es típus objektumstreamsorszámot és tagsági indexet ad, minden más axkUnknown-dé válik találgatás helyett

{ klasszikus tábla, ISO 32000-1 7.5.4 szakasz }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ kereszthivatkozási stream, ISO 32000-1 7.5.8 szakasz }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Semmi az utak közül nem arányos a /Size-szal. Ez az újraírás teljes értelme: a trailer méretértéke metaadatként kerül tovább, és az inkrementális revízió írásakor használatos, de sosem vezet kiosztást. A regressziós készlet egy olyan javítójeggyel rögzíti ezt, amelynek lapfája az 1 000 000 000 és 1 000 000 001 objektumszámokon ül, egy /Size 1000000002-t deklaráló trailer alatt. A régi sűrű megvalósítás elutasította azt a fájlt; a ritka index mindkét hivatkozást feloldja, és megőrzi a deklarált méretet a kimeneti trailerben

Hogyan old fel egy objektumszámot a PDFiumPas Delphiben: egy klasszikus kereszthivatkozási tábla megszorozza a mért sorszélességgel, míg egy kereszthivatkozási stream a megelőző alszakaszok számait halmozza, mielőtt a /W tömbből összegzett mezőszélességekkel szorozna
Mindkét keresés tiszta számítás, tehát egyik sem arányos a trailerben deklarált objektumszámmal

Hibrid revíziók, /Prev láncok és a védelmek körülöttük

A revíziós elsőbbség az, ahol egy naiv lusta index elromlik. A PDFiumPas a láncot a startxref-től legújabb-előbb sorrendben járja, és a keresést az első válaszoló szakasznál állítja meg, ami az elsőbbségi szabályt reprodukálja összefésült tábla felépítése nélkül. A hibrid-hivatkozású fájlokat (§7.5.8.4) a klasszikus ág belül kezeli: amikor a trailer /XRefStm-et hordoz, a kiegészítő stream szakasz az őt megnevező klasszikus szakasz előtt regisztrálódik, így a sima tábla számára láthatatlan tömörített objektumok is megtalálhatók, miközben a klasszikus bejegyzések megőrzik érvényességüket. A régebbi revíziók ezután /Prev-en keresztül követődnek

Két védelem korlátozza azt a járást, és mindkettő számít sérült fájloknál. Minden meglátogatott ofszet rögzítésre kerül, így a láncba visszamutató /Prev leáll pörgés helyett, és a bejárási mélységet a MaxRecursionDepth koronázza, amelynek alapértéke 1024. A titkosítási jelző az egész láncra halmozódik, nem csak a legújabb trailerből olvasódik, mert egy olyan dokumentum, amelynek legújabb trailere kihagyja az /Encrypt-et, még mindig titkosított lehet hátrébb; a revíziókat hozzáfűző hívók arra a jelzőre támaszkodnak, hogy ne írjanak nyílt szöveges objektumokat titkosított fájlba

Hogyan járja végig a PDFiumPas egy hibrid PDF revízióláncát Delphiben: a szakaszok a startxref-től legújabb előbb regisztrálódnak, egy kiegészítő XRefStm szakasz az őt megnevező klasszikus tábla elé kerül, és a /Prev járás meglátogatott ofszetekkel és mélységi korláttal határolódik
A keresés az első válaszoló szakasznál áll meg, ami a revíziós elsőbbséget reprodukálja összefésült tábla felépítése nélkül

2-es típusú bejegyzések: miért vár az objektumstream

Egy 2-es típusú bejegyzés objektumstreamet nevez meg, és a PDFiumPas nem érinti azt a streamet, amíg egy hívó nem kér belőle tagot. Amikor végre megteszi, ellenőrzésre kerül a /Type /ObjStm, a /N-t az objektumkerethez, a /First-t a dekódolt bájtok plafonjához mérik, és a /N-t ésszerűségi vizsgálat alá vetik a /First-höz képest, mivel minden fejlécpárnak legalább négy bájtra van szüksége. Csak ezután fújódik fel a stream, és a fejlécátvizsgálás a kért tagnál és annak utódjánál áll meg, nem pedig teljes tagtáblát épít. Egyszerre egy dekódolt objektumstream marad megőrizve, ami a helyes kompromisszum, amikor egy lapfaág egyetlen ObjStm-be tömörül; az objektumstream- és prediktordekódolás Delphiben írásunk azt tárgyalja, mi történik az említett felfújási lépésen belül (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { egy megőrzött index, sok generációtudatos olvasás }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { a Source az Öné marad }
  end;
end;

Ahol a gyorsítótár felhagy az ígéretekkel

Az index egy pillanatfelvétel, és érdemes egyenesen kimondani. A szakaszok egyszer, az Initialize-ben elemződnek; ha az alapul szolgáló stream ezután módosul, minden gyorsítótárazott bejegyzés elavult, és az osztály észre sem veszi. A TPdfSparseDictionaryReader az indexet a forrás hívó tulajdonában lévő élettartamára tartja meg, ami pontosan az, amit egy lapfa feletti rekurzív járás akar, és pontosan az, amit egy átírás ideje alatt nem szabad megtenni. A bejegyzés-gyorsítótár lapos tömb, lineárisan kereshető, és negatív eredményeket is tárol, így néhány száz keresés olcsó, néhány százezer nem. A ReadDictionary pontos generációs egyezést követel, míg a ReadLatestDictionary az aktívat oldja fel, és a különbség szándékos: a hivatkozásfeloldásnak az első kell, a katalógusvizsgálatnak az utóbbi. Ahol ezek a határok nem tartathatók meg, a környező egységek a hagyományos egész-fájl elemzőre esnek vissza, nem szűkítve azon fájlok halmazát, amelyek még működnek; ezt a mintát alkalmazzuk a nagy PDF-ek igény szerinti streamelése esetén is

Az átfordítói regressziók mindhárom eszközláncra lefedik ugyanazt a viselkedést, ideértve egy állítást, hogy egy 2 MiB-os forrás sosem lát 1 MiB-nál nagyobb egyetlen olvasást sem. Ha Ön Delphi, C++Builder vagy Lazarus kódot tart karban, amely közvetlenül nyúl a PDF-struktúrához, és belefáradt abba, hogy négy szótárért egész-fájl elemzési költséget fizessen, a ritka index és körülötte a nyilvános varrat a PDFiumPas Delphi PDFium komponensben szállít