Odborný článok

Pamäťovo mapované čítanie PDF v Delphi: posuvné okno

PDFlibPas dokáže otvoriť lokálne PDF cez ohraničený pamäťovo mapovaný pohľad iba na čítanie: LoadFromMappedFile a DAOpenMappedFile udržiavajú nad súborom presne jedno posuvné okno, podľa potreby ho mapujú nanovo a každý výrez objektu obslúžia čítaniami z absolútneho offsetu. Delphi PDF library nikdy nedrží celý zdroj v pamäti, takže využitie adresného priestoru zostáva pri raste súboru konštantné. Návrh je určený pre jednu záťaž: gigabajtové PDF, pri ktorých parser dokončil načítanie a stále sa vracia na disk, objekt po objekte a fragment streamu po fragmente streamu

Prečo zostávajú riedke čítania drahé aj po načítaní PDF?

Načítanie PDF neznamená, že je prečítané celé, a pri viacgigabajtovom súbore sa práve v tejto medzere stráca čas. Cross-reference tabuľka alebo cross-reference stream (ISO 32000-1 §7.5.4 a §7.5.8) zaznamenáva iba to, kde sa začína každý nepriamy objekt. Bajty prídu neskôr, keď sa renderuje stránka, dekóduje fontový program alebo extrahuje vložený file stream (ISO 32000-1 §7.11.4). Archív s veľkosťou 2 GB a desaťtisícmi objektov sa zmení na desaťtisíce malých neusporiadaných čítaní a v čase načítania nie je známe ani jedno z nich

Cesta, ktorou tieto čítania kedysi prechádzali, bolo spoločné Seek nasledované Read na jednom pozičnom streame, a zlyháva v dvoch smeroch naraz. Každý fragment zaplatí za čítanie súboru aj vtedy, keď stránka už býva v cache operačného systému, a kurzor je zdieľaný meniteľný stav, takže lokálny súbor a zdroj byte-range za progresívnym načítavaním rozsahov PDF s prefetchom nemohli používať rovnaký parser kód bez vzájomného zápasu o pozíciu. PDFlibPas rieši oboje tým, že čítanie z absolútneho offsetu povyšuje z optimalizácie na kontrakt

Čo garantuje TPDFReadAtStream?

TPDFReadAtStream garantuje čítanie z absolútneho offsetu, ktoré nezávisí od logického kurzora streamu ani ho nemení. Je to abstraktný potomok TStream s presne jednou virtuálnou metódou a oba zdroje v knižnici nezávislé od kurzora z neho dedia: TReadOnlyMappedFileStream pre lokálne súbory a TByteRangeStream pre vzdialené zdroje poskytované po rozsahoch. Čítačka výrezov objektov sa raz opýta, či jej zdroj je TPDFReadAtStream, a keď nie je, vráti sa k starému sledu seek-then-read, takže obyčajný file stream alebo memory stream funguje ďalej bez zmeny

type
  // Streamy iba na čítanie, ktorých absolútne čítania obchádzajú spoločné Seek a Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Prístup iba na čítanie k jednému lokálnemu súboru cez okná
  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;

Rozdiel je dôležitejší, než naznačuje signatúra. ReadAt použije odovzdaný offset a nechá Position presne tam, kde bol, čo umožňuje vnoreným úrovniam parsera vydávať čítania bez tanca save-and-restore okolo každého volania. TReadOnlyMappedFileStream stále implementuje Read, Seek a Size ako každý iný TStream, Seek ohraničí logickú pozíciu v rámci súboru a Write vždy vráti 0, pretože zdroj je otvorený iba na čítanie

Otvorenie PDF cez mapovaný pohľad v Delphi

Mapovaný zdroj otvárajú dva explicitné vstupné body a ani jeden nemení správanie vstupných bodov, ktoré už používate. LoadFromMappedFile načíta a vyberie dokument; DAOpenMappedFile vráti Direct Access handle nad tým istým súborom, čo je režim, ktorý chcete pri spájaní a rozdeľovaní gigabajtových PDF cez Direct Access. LoadFromFile a DAOpenFile si zachovávajú svoju sémantiku zdieľania súborov, chýb a kompatibility, takže volajúcim, ktorí sa neprihlásia, sa nič neposunie. Oba mapované vstupné body prijímajú požadovaný WindowSize v bajtoch a bitovú masku Options a pri oboch je možné zadať 0 pre ktorúkoľvek z nich

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 vyberie predvolené okno 64 MiB; mapovanie je tu povinné
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Odložená extrakcia teraz prechádza mapovanými oknami namiesto seekovania
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Čo vlastne vynucuje PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING mení tichý fallback na okamžité a diagnostikovateľné zlyhanie pri otváraní. Keď Options zostane na 0, oba vstupné body prijmú fallback na file stream iba na čítanie: ak platforma nemá kód pre mapovanie alebo volanie mapovania zlyhá, dokument sa aj tak otvorí a každé čítanie pôjde cez normálny file stream. Pri nastavenom príznaku PDFlibPas prijme vstup iba vtedy, keď sa vytvoril prvý pohľad, a odmietnutie nahlási cez LastErrorCode 401 namiesto načítania dokumentu, ktorý potichu funguje presne ako stará cesta

Vo Windows mapovaný stream otvorí druhý handle iba na čítanie s FILE_SHARE_READ, FILE_SHARE_WRITE a FILE_SHARE_DELETE spolu s FILE_FLAG_RANDOM_ACCESS, vytvorí nad ním mapovanie PAGE_READONLY a prvé okno namapuje už v konštruktore. Eager mapovanie je celý zmysel: zlyhanie „mapping required“ sa prejaví v LoadFromMappedFile, nie pri prvom lenivo odloženom čítaní objektu uprostred renderovacej úlohy. Treba si však presne uvedomiť, kde sa garancia končí. Kód mapovania sa kompiluje iba pre ciele Windows a súbor s nulovou veľkosťou sa o mapovanie vôbec nepokúsi, takže PDF_MAPPED_FILE_REQUIRE_MAPPING je požiadavka, ktorá môže legitímne zlyhať, nie prenositeľný sľub. Záporný WindowSize alebo ľubovoľný bit v Options okrem jedinej zdokumentovanej hodnoty sa odmietne priamo s rovnakou chybou 401

Jedno okno, nanovo mapované na alokačnú granularitu

Uchováva sa vždy iba jeden pohľad, a práve to udržiava využitie adresného priestoru nezávislé od veľkosti súboru. WindowSize 0 vyberie 64 MiB; hodnota menšia než systémová alokačná granularita sa na ňu zvýši; hodnota väčšia než 1 GiB sa ohraničí; výsledok sa zaokrúhli nahor na celý počet jednotiek granularity, vo Windows na 65536 bajtov, ak GetSystemInfo nenahlási inú hodnotu dwAllocationGranularity. Keď čítanie dopadne mimo aktuálneho pohľadu, PDFlibPas ho odmapuje, zarovná požadovaný offset nadol na hranicu granularity a na tomto mieste namapuje čerstvé okno. Posledné okno sa ohraničí fyzickou veľkosťou súboru, takže pohľad nikdy nepresiahne jeho koniec

Jedno čítanie môže prekročiť ľubovoľný počet okien: cyklus skopíruje všetko, čo dokáže dodať aktuálny pohľad, zmapuje ďalšie okno a pokračuje; požiadavka idúca za koniec vráti skrátený počet namiesto zlyhania. PDFlibPas zámerne nerobí to, že by vám odovzdal ukazovateľ do pohľadu, pretože ďalšie čítanie cez hranicu okna ho zneplatní a žiadny volajúci by sa proti tomu rozumne neubránil. Mapované bajty sa kopírujú priamo do cieľových bufferov vlastnených parserom, čím odpadá ďalší vstupný buffer súboru aj prepínanie pozície, knižnica však netvrdí, že finálne úložisko parsera je zero-copy. Windowing na strane čítania sa skladá aj so zápisom, pretože posun referencií na úrovni bajtov počas rýchleho spájania PDF streamuje bajty objektu von, zatiaľ čo mapovaný zdroj ich streamuje dnu. Trade-off veľkosti okna je očividný: menšie okno zaberie menej adresného priestoru a častejšie sa mapuje nanovo, čo je v 32-bitovom procese zvyčajne správna voľba

Čo chráni zámok a čo hlási GetMappedFileInfo

Jedna critical section pokrýva mapovaný pohľad, kurzor fallbackového súboru, logickú pozíciu aj štatistiky a rozdelenie medzi oboma metódami čítania z toho priamo vyplýva. ReadAt vezme zámok a zavolá internú čítačku bez zámku; Read vezme ten istý zámok, zavolá tú istú internú čítačku na aktuálnej logickej pozícii a potom ju posunie. Opätovné použitie internej funkcie namiesto verejného ReadAt zabraňuje rekurzívnemu zamykaniu a držanie zámku počas celej kopírovacej slučky udržiava správnosť remapovania jedného okna pri súbežných volaniach. Pred portovaním sa oplatí poznať jeden detail Free Pascalu: unit FPC Windows deklaruje vlastný record s názvom TCriticalSection, takže pole aj jeho vytvorenie musia byť zapísané ako SyncObjs.TCriticalSection. Delphi neúplnú formu bez problémov skompiluje, FPC ju vyrieši na record bez Create, Enter alebo Leave

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 je false vždy, keď je aktívny prenositeľný fallback na file stream, a je to jediné pole dokazujúce, že mapovanie sa nikdy nevytvorilo
  • windowSize je efektívne zarovnané okno, nie hodnota, ktorú ste požadovali, a mappedBytes je v koncovom okne menšie než ono
  • mappedOffset je na alokáciu zarovnaný začiatok uchovávaného pohľadu alebo -1, keď momentálne nie je aktívny žiadny pohľad
  • readCalls počíta úspešné požiadavky na čítanie v rámci rozsahu, bytesRead počíta bajty skopírované volajúcim a remapCount zahŕňa aj počiatočný pohľad

Cielené regresie pokrývajú absolútne čítania cez hranicu okna, zachovanie logického kurzora, krátke čítania na konci, neplatné offsety, odmietnuté zápisy, remapovanie medzi oddelenými oknami, odloženú extrakciu 220 KB nekomprimovateľnej prílohy a neplatnosť štatistík po DACloseFile; headless suite Win32 aj Win64 objavila po 1467 testov a všetky prešli bez ignorovaných, zlyhaných, chybných či uniknutých výsledkov. Ak v Delphi alebo C++Builderi pracujete s gigabajtovými PDF a profiler stále ukazuje skôr na čítania súboru než na parsing, mapované vstupné body stoja za popoludnie merania a GetMappedFileInfo vám povie, či ste naozaj dostali mapovanie. Úplná referencia API a trial build sú na stránke PDFlibPas Delphi PDF library