Műszaki cikk

Memóriatérképes PDF-olvasás Delphiben csúszó ablakkal

A PDFlibPas korlátozott, csak olvasható memóriatérképes nézeten keresztül nyit meg helyi PDF-et: a LoadFromMappedFile és a DAOpenMappedFile pontosan egy csúszó ablakot tart fenn a fájlon, szükség szerint újratérképezi, és minden objektumszeletet abszolút offset alapú olvasással szolgál ki. A Delphi PDF library soha nem tartja memóriában a teljes forrást, ezért a címtérhasználat a fájl növekedésével is állandó marad. A megoldás egyetlen terhelésre készült: gigabájtos PDF-ekre, amelyeknél a parser befejezte a betöltést, de objektumról objektumra és streamrészletről streamrészletre még visszajár a lemezhez

Miért marad drága a ritka olvasás a PDF betöltése után is?

A PDF betöltése nem jelenti az olvasás végét, és több gigabájtos fájlnál éppen ez a rés viszi el az időt. A kereszt-hivatkozási tábla vagy stream (ISO 32000-1 §7.5.4 és §7.5.8) csak azt rögzíti, hogy hol kezdődik az egyes indirekt objektumok sora. A bájtok később érkeznek, amikor egy oldalt renderelünk, egy fontprogramot dekódolunk, vagy egy beágyazott fájl streamjét (ISO 32000-1 §7.11.4) nyerjük ki. Egy 2 GB-os archívum több tízezer objektuma több tízezer kis, rendezetlen olvasássá válik, amelyek közül egyiket sem ismerjük a betöltéskor

Ezek az olvasások korábban egy közös Seek, majd ugyanazon pozicionális streamen végzett Read útvonalán mentek, és ez egyszerre két irányban hibás. Minden részlet fizet egy fájlolvasást akkor is, ha az oldal már az operációs rendszer cache-ében van, ráadásul a kurzor megosztott, módosítható állapot, ezért egy helyi fájl és a prefetch-csel végzett progresszív PDF range-betöltés mögötti byte-range forrás nem futhatott ugyanazzal a parserkóddal anélkül, hogy össze ne akadtak volna a pozíción. A PDFlibPas mindkettőt azzal javítja, hogy az abszolút offset alapú olvasást optimalizálásból szerződéssé emeli

Mit garantál a TPDFReadAtStream?

A TPDFReadAtStream olyan abszolút offseten végzett olvasást garantál, amely nem függ a logikai streamkurzortól, és nem is módosítja azt. Ez egy absztrakt TStream-leszármazott, pontosan egy virtuális metódussal, és a library mindkét kurzorfüggetlen forrása ebből származik: a TReadOnlyMappedFileStream helyi fájlokhoz, a TByteRangeStream távolról kiszolgált range-ekhez. Az objektumszelet-olvasó egyszer ellenőrzi, hogy a forrás TPDFReadAtStream-e, és ha nem, visszaesik a régi seek-then-read sorozatra, így a hagyományos fájl- vagy memorystream változatlanul működik

type
  // A csak olvasható streamek abszolút olvasásai elkerülik a közös Seek és Read párost
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Ablakos, csak olvasható hozzáférés egy helyi fájlhoz
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

A különbség fontosabb, mint amit a szignatúra sugall. A ReadAt a neki átadott offsetet használja, és a Position értékét pontosan változatlanul hagyja, így a beágyazott parser-szintek minden hívás köré épített mentés-visszaállítás nélkül olvashatnak. A TReadOnlyMappedFileStream ugyanúgy megvalósítja a Read, Seek és Size műveleteket, mint bármely más TStream, a Seek a fájlon belülre korlátozza a logikai pozíciót, a Write pedig mindig 0-val tér vissza, mert a forrás csak olvasható

PDF megnyitása leképezett nézeten keresztül Delphiben

Két explicit belépési pont nyit meg leképezett forrást, és egyik sem változtatja meg a már használt belépési pontok működését. A LoadFromMappedFile betölti és kiválasztja a dokumentumot, a DAOpenMappedFile pedig Direct Access handle-t ad vissza ugyanahhoz a fájlhoz; ezt érdemes használni, amikor gigabájtos PDF-eket egyesítesz és darabolsz Direct Access segítségével. A LoadFromFile és DAOpenFile fájlmegosztási, hiba- és kompatibilitási szemantikája változatlan, ezért az opt-in nélkül működő hívóknál semmi sem mozdul el. Mindkét leképezett belépési pont bájtban kért WindowSize értéket és Options bitmaszkot kap, és mindkettőnél elfogadható a 0 mindkét paraméterhez

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // A WindowSize 0 a 64 MiB alapértéket választja; itt a leképezés kötelező
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // A késleltetett kinyerés most leképezett ablakokon jár végig seek helyett
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Mit kényszerít ki valójában a PDF_MAPPED_FILE_REQUIRE_MAPPING?

A PDF_MAPPED_FILE_REQUIRE_MAPPING a csendes fallbacket azonnali, diagnosztizálható megnyitási hibává alakítja. Ha az Options értéke 0, mindkét belépési pont elfogadja a csak olvasható fájlstreames fallbacket: ha a platformon nincs leképezési kód, vagy a leképezési hívás meghiúsul, a dokumentum akkor is megnyílik, és minden olvasás hagyományos fájlstreamen megy tovább. A flaggel a PDFlibPas csak akkor fogadja el a bemenetet, ha az első nézet létrejött, és a dokumentum csendes visszaesés nélküli elutasítását a LastErrorCode 401 jelzi, nem pedig egy olyan betöltést kapsz, amely észrevétlenül pontosan a régi úton működik

Windowson a leképezett stream egy második, csak olvasható handle-t nyit FILE_SHARE_READ, FILE_SHARE_WRITE és FILE_SHARE_DELETE megosztással, valamint FILE_FLAG_RANDOM_ACCESS jelzővel, erre PAGE_READONLY leképezést hoz létre, és a konstruktorban leképezi az első ablakot. A korai leképezés a lényeg: a „mapping required” hiba már a LoadFromMappedFile hívásnál megjelenik, nem pedig az első lusta objektumolvasásnál egy renderelési feladat felénél. Azt azonban tisztázni kell, meddig tart a garancia. A leképezési kód csak Windows célokon fordul le, a nulla bájtos fájl pedig egyáltalán nem próbál leképezést, ezért a PDF_MAPPED_FILE_REQUIRE_MAPPING olyan kérés, amely jogszerűen meghiúsulhat, nem pedig hordozható ígéret. A negatív WindowSize, illetve az Options dokumentált egyetlen értékén kívüli bármely bit azonnal, ugyanazzal a 401-es hibával elutasításra kerül

Egy ablak, az allokációs granularitáshoz igazítva újraleképezve

Mindig csak egy nézet marad meg, ezért a címtérhasználat független a fájl méretétől. A 0 értékű WindowSize 64 MiB-t választ, a rendszer allokációs granularitásánál kisebb érték erre a minimumra emelkedik, az 1 GiB-nál nagyobb érték pedig korlátozódik, majd az eredményt egész granularitási egységekre kerekíti a kód: Windowson ez 65536 bájt, hacsak a GetSystemInfo más dwAllocationGranularity értéket nem jelent. Amikor egy olvasás kilép az aktuális nézetből, a PDFlibPas megszünteti azt, a kért offsetet lefelé egy granularitási határra igazítja, és ott új ablakot képez. Az utolsó ablakot a fizikai fájlmérethez igazítja, ezért a nézet soha nem nyúlik túl a fájl végén

Egyetlen olvasás tetszőleges számú ablakon átnyúlhat: a ciklus bemásolja, amit az aktuális nézet ki tud szolgálni, újraleképez, majd folytatja, a fájl végén túlfutó kérés pedig hiba helyett rövid darabszámmal tér vissza. A PDFlibPas szándékosan nem ad a kezedbe nézeten belüli pointert, mert a következő ablakváltó olvasás érvényteleníti, és ezt egyetlen ésszerű hívó sem tudná biztonságosan kezelni. A leképezett bájtokat közvetlenül a parser saját célpuffereibe másolja, így elmarad a külön fájl-bemeneti puffer és a pozícióváltogatás, de a library nem ígér zero-copy végső parser-tárolást. Az ablakos olvasás az írási oldallal is összeilleszthető, mert a gyors PDF-egyesítés közbeni byte-szintű referenciaeltolás kivezeti az objektumbájtokat, miközben a leképezett forrás bevezeti őket. Az ablakméret kompromisszuma kézenfekvő: a kisebb ablak kevesebb címtartományt tart meg, de gyakrabban képez újra, ami általában helyes választás 32 bites folyamatban

Mit véd a lock, és mit jelent a GetMappedFileInfo?

Egyetlen kritikus szakasz védi a leképezett nézetet, a fallback fájlkurzorát, a logikai pozíciót és a statisztikát, és ebből közvetlenül következik a két olvasási metódus felosztása. A ReadAt felveszi a lockot, majd a lockmentes belső olvasót hívja; a Read ugyanazt a lockot veszi fel, a belső olvasót az aktuális logikai pozíción hívja, majd előrelépteti azt. A belső függvény újrahasznosítása a publikus ReadAt helyett kerüli a rekurzív lockolást, a teljes másolási ciklus alatt tartott lock pedig a párhuzamos hívások mellett is helyessé teszi az egyablakos újraleképezést. Egy Free Pascal-részletet érdemes ismerni portolás előtt: az FPC Windows unitja saját, TCriticalSection nevű rekordot deklarál, ezért a mezőt és a példányosítást SyncObjs.TCriticalSection alakban kell írni. A Delphi gond nélkül lefordítja a minősítés nélküli alakot, az FPC viszont egy olyan rekordra oldja fel, amelynek nincs Create, Enter vagy Leave metódusa

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped hamis, amikor a hordozható fájlstreames fallback aktív, és ez az egyetlen mező, amely bizonyítja, hogy nem jött létre leképezés
  • windowSize a tényleges, igazított ablak, nem a kért érték, a mappedBytes pedig a záró ablakban kisebb ennél
  • mappedOffset a megtartott nézet allokációhoz igazított kezdete, vagy -1, ha jelenleg nincs aktív nézet
  • readCalls a sikeres, fájlon belüli olvasási kéréseket számolja, a bytesRead a hívóknak másolt bájtokat, a remapCount pedig a kezdeti nézetet is tartalmazza

A célzott regressziók lefedik az ablakokon átnyúló abszolút olvasásokat, a logikai kurzor megőrzését, a fájlvégi rövid olvasásokat, az érvénytelen offseteket, az elutasított írásokat, az egymástól távoli ablakok közötti újraleképezést, egy 220 KB-os, nehezen tömöríthető melléklet késleltetett kinyerését, valamint a statisztika érvénytelenedését a DACloseFile után; a Win32 és Win64 headless tesztsorozat egyenként 1467 tesztet fedezett fel, és mind átment, kihagyott, hibás, kivételes vagy szivárgó eredmény nélkül. Ha gigabájtos PDF-ekkel dolgozol Delphiben vagy C++Builderben, és a profiler mindig a fájlolvasásokra mutat, nem a parsingra, a leképezett-fájlos belépési pontokra érdemes egy délutánt mérésre szánni, a GetMappedFileInfo pedig megmutatja, hogy valóban leképezést kaptál-e. A teljes API-referencia és a próbaverzió a PDFlibPas Delphi PDF library oldalán található