Technický článek

Paměťově mapované čtení PDF v Delphi: posuvné okno

PDFlibPas umí otevřít lokální PDF přes omezené paměťově mapované okno jen pro čtení: LoadFromMappedFile a DAOpenMappedFile udržují přesně jedno posuvné okno nad souborem, podle potřeby ho přemapují a každý výřez objektu obslouží čtením z absolutního offsetu. Knihovna PDF pro Delphi nikdy nedrží celý zdroj v paměti, takže využití adresního prostoru zůstává při růstu souboru konstantní. Návrh míří na jedinou úlohu: gigabajtová PDF, u nichž parser dokončil načítání, ale stále se vrací na disk objekt po objektu a fragment streamu po fragmentu streamu

Proč zůstává náhodné čtení drahé i po načtení PDF

Načtení PDF neznamená konec čtení a u několika gigabajtů je právě tato mezera zdrojem času. Křížová referenční tabulka nebo cross-reference stream (ISO 32000-1 §7.5.4 a §7.5.8) pouze zaznamenává, kde začíná každý nepřímý objekt. Bajty dorazí později, když se renderuje stránka, dekóduje program fontu nebo extrahuje embedded file stream (ISO 32000-1 §7.11.4). Archiv o velikosti 2 GB s desítkami tisíc objektů se promění v desítky tisíc malých neuspořádaných čtení a při načítání nelze předem znát žádné z nich

Cesta těchto čtení dříve vedla přes sdílené Seek následované Read nad jedním pozičním streamem a selhávala dvěma směry najednou. Každý fragment platí za čtení souboru, i když stránka už je v cache operačního systému, a kurzor je sdílený měnitelný stav, takže lokální soubor a byte-range zdroj za progresivním načítáním PDF rozsahů s prefetchingem nemohly používat stejný kód parseru bez vzájemného přepisování pozice. PDFlibPas řeší obojí tím, že čtení z absolutního offsetu povyšuje z optimalizace na smlouvu

Co zaručuje TPDFReadAtStream

TPDFReadAtStream zaručuje čtení na absolutním offsetu, které nezávisí na logickém kurzoru streamu ani ho nemění. Jde o abstraktní potomka TStream s právě jednou virtuální metodou a oba zdroje v knihovně nezávislé na kurzoru z něj dědí: TReadOnlyMappedFileStream pro lokální soubory a TByteRangeStream pro vzdálené zdroje obsluhované po rozsazích. Čtečka výřezu objektu se jednou zeptá, zda je jejím zdrojem TPDFReadAtStream, a pokud není, vrátí se ke staré sekvenci seek-then-read, takže běžný file stream nebo memory stream dál funguje beze změny

type
  // Read-only streamy, jejichž absolutní čtení obchází sdílené Seek a Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Přístup jen pro čtení v okně k jedinému lokálnímu souboru
  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;

Rozdíl je důležitější, než napovídá signatura. ReadAt používá předaný offset a ponechá Position přesně tam, kde byl, což umožňuje vnořeným úrovním parseru číst bez ukládání a obnovování pozice kolem každého volání. TReadOnlyMappedFileStream stále implementuje Read, Seek a Size jako každý jiný TStream, Seek omezí logickou pozici do rozsahu souboru a Write vždy vrací 0, protože zdroj se otevírá jen pro čtení

Otevření PDF přes mapované okno v Delphi

Mapovaný zdroj otevírají dva explicitní vstupní body a žádný z nich nemění chování vstupních bodů, které už používáte. LoadFromMappedFile načte a vybere dokument; DAOpenMappedFile vrací Direct Access handle nad stejným souborem, což je režim, který chcete při slučování a dělení gigabajtových PDF přes Direct Access. LoadFromFile a DAOpenFile si ponechávají své významy sdílení souboru, chyb a kompatibility, takže u volajících, kteří se nepřihlásí, se nic neposune. Oba mapované vstupní body přijímají požadovaný WindowSize v bajtech a bitovou masku Options a u obou lze zadat 0 pro libovolný z nich

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 zvolí výchozích 64 MiB; mapování je zde 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á extrakce nyní prochází mapovaná okna místo seekování
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Co přesně vynucuje PDF_MAPPED_FILE_REQUIRE_MAPPING

PDF_MAPPED_FILE_REQUIRE_MAPPING mění tichý fallback na okamžité a diagnostikovatelné selhání při otevírání. Když je Options 0, oba vstupní body přijmou fallback na file stream jen pro čtení: pokud platforma nemá kód pro mapování nebo volání mapování selže, dokument se přesto otevře a všechna čtení povedou přes běžný file stream. S příznakem PDFlibPas přijme vstup pouze tehdy, když bylo vytvořeno první view, a odmítnutí ohlásí přes LastErrorCode 401 místo načtení dokumentu, který by potichu fungoval přesně jako stará cesta

V systému Windows mapovaný stream otevře druhý handle jen pro čtení s FILE_SHARE_READ, FILE_SHARE_WRITE a FILE_SHARE_DELETE plus FILE_FLAG_RANDOM_ACCESS, vytvoří nad ním mapování PAGE_READONLY a první okno namapuje už v konstruktoru. Dychtivé mapování je celý smysl: selhání požadavku na mapování se objeví v LoadFromMappedFile, nikoli při prvním líném čtení objektu uprostřed renderovací úlohy. Je ale důležité vědět, kde záruka končí. Kód mapování se kompiluje pouze pro cíle Windows a soubor s nulovou délkou se o mapování vůbec nepokusí, takže PDF_MAPPED_FILE_REQUIRE_MAPPING je požadavek, který může legitimně selhat, nikoli přenosný slib. Záporný WindowSize nebo jakýkoli bit v Options kromě jediné dokumentované hodnoty se rovnou odmítne stejnou chybou 401

Jedno okno přemapované na alokační granularitu

V paměti se vždy drží jen jedno view a právě to udržuje využití adresního prostoru nezávislé na velikosti souboru. WindowSize 0 zvolí 64 MiB, hodnota pod systémovou alokační granularitou se na ni zvýší, hodnota nad 1 GiB se omezí a výsledek se zaokrouhlí nahoru na celý počet granulačních jednotek, v systému Windows 65536 bajtů, pokud GetSystemInfo neohlásí jiné dwAllocationGranularity. Když čtení dopadne mimo aktuální view, PDFlibPas ho odmapuje, zarovná požadovaný offset dolů na hranici granularity a na tomto místě namapuje nové okno. Poslední okno se omezí na fyzickou velikost souboru, takže view nikdy nepřesáhne jeho konec

Jedno čtení může překročit libovolný počet oken: smyčka zkopíruje tolik, kolik poskytne aktuální view, přemapuje a pokračuje, a požadavek za koncem vrátí zkrácený počet místo selhání. PDFlibPas záměrně nedělá to, že by vám předal pointer do view, protože další čtení přes hranici okna ho zneplatní a žádný volající by se před tím rozumně neochránil. Mapované bajty se kopírují přímo do bufferů vlastněných parserem, čímž odpadá další vstupní buffer souboru a přepínání pozice, ale knihovna netvrdí, že výsledné úložiště parseru je zero-copy. Okénkové čtení na straně čtení se skládá i se stranou zápisu, protože posun odkazů na úrovni bajtů při rychlém slučování PDF streamuje bajty objektů ven, zatímco mapovaný zdroj je streamuje dovnitř. Kompromis velikosti okna je zřejmý: menší okno zabere méně adresního prostoru a přemapovává častěji, což je obvykle správná volba uvnitř 32bitového procesu

Co chrání zámek a co hlásí GetMappedFileInfo

Jedna kritická sekce pokrývá mapované view, kurzor fallbackového souboru, logickou pozici i statistiky a rozdělení mezi dvě metody čtení z ní přímo vyplývá. ReadAt získá zámek a zavolá interní čtečku bez zámku; Read získá stejný zámek, zavolá tutéž interní funkci na aktuální logické pozici a pak ji posune. Opětovné použití interní funkce místo veřejného ReadAt zabraňuje rekurzivnímu zamykání a držení zámku přes celou kopírovací smyčku udržuje přemapování jediného okna správné i při souběžných voláních. Před portováním stojí za znalost jeden detail Free Pascalu: jeho jednotka FPC Windows deklaruje vlastní record jménem TCriticalSection, takže pole i jeho vytvoření musí být zapsány jako SyncObjs.TCriticalSection. Delphi neúplný kvalifikovaný tvar bez problémů zkompiluje, FPC ho vyřeší na record bez Create, Enter a 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, když je aktivní přenosný fallback na file stream, a je to jediné pole dokazující, že mapování nikdy nevzniklo
  • windowSize je výsledné zarovnané okno, nikoli vyžádaná hodnota, a mappedBytes je v koncovém okně menší
  • mappedOffset je na alokaci zarovnaný začátek drženého view nebo -1, když žádné view právě není aktivní
  • readCalls počítá úspěšné požadavky na čtení uvnitř rozsahu, bytesRead počítá bajty zkopírované volajícím a remapCount zahrnuje počáteční view

Cílené regresní testy pokrývají absolutní čtení přes hranice oken, zachování logického kurzoru, zkrácená čtení na konci, neplatné offsety, odmítnuté zápisy, přemapování mezi oddělenými okny, odloženou extrakci 220KB nekomprimované přílohy a neplatnost statistik po DACloseFile; headless sady Win32 i Win64 objevily po 1467 testech a všechny prošly bez ignorovaných, neúspěšných, chybových nebo uniklých výsledků. Pokud v Delphi nebo C++Builderu pracujete s gigabajtovými PDF a profiler stále ukazuje na čtení souboru místo na parsování, stojí za to mapované vstupní body odpoledne měřit a GetMappedFileInfo vám řekne, zda jste skutečně získali mapování. Kompletní reference API a trial build najdete na stránce knihovny PDFlibPas Delphi PDF