Technisch artikel

Memory-mapped PDF lezen in Delphi: sliding window

PDFlibPas kan een lokale PDF openen via een begrensde read-only memory-mapped view: LoadFromMappedFile en DAOpenMappedFile houden precies één sliding window boven het bestand, mappen die op aanvraag opnieuw en leveren elke objectslice via reads op absolute offsets. De Delphi PDF library houdt de volledige bron nooit in het geheugen, dus het gebruik van de address space blijft vlak terwijl het bestand groeit. Het ontwerp is bedoeld voor één workload: gigabyte-PDF's waarbij de parser klaar is met laden maar nog steeds terug naar schijf gaat, object voor object en streamfragment voor streamfragment

Waarom blijven sparse reads duur nadat de PDF is geladen?

Een PDF laden betekent niet dat het lezen klaar is, en bij een bestand van meerdere gigabytes gaat juist in dat gat de tijd zitten. Een cross-reference table of cross-reference stream (ISO 32000-1 §7.5.4 en §7.5.8) registreert alleen waar elk indirect object begint. De bytes komen later, wanneer een pagina wordt gerenderd, een fontprogramma wordt gedecodeerd of een embedded file stream (ISO 32000-1 §7.11.4) wordt geëxtraheerd. Een archief van 2 GB met tienduizenden objecten wordt zo tienduizenden kleine ongeordende reads, en bij het laden is nog geen enkele daarvan bekend

Die reads liepen vroeger via een gedeelde Seek gevolgd door Read op één positional stream, en dat faalt in twee richtingen tegelijk. Elk fragment betaalt voor een file read, ook wanneer de pagina al in de cache van het besturingssysteem staat, en de cursor is gedeelde mutable state. Daardoor konden een lokaal bestand en de byte-range-bron achter progressive PDF range loading met prefetch niet dezelfde parsercode uitvoeren zonder om de positie te vechten. PDFlibPas lost beide problemen op door lezen op absolute offsets van optimalisatie tot contract te verheffen

Wat garandeert TPDFReadAtStream?

TPDFReadAtStream garandeert een read op een absolute offset die niet afhankelijk is van de logische streamcursor en die evenmin verandert. Het is een abstracte TStream-descendant met precies één virtual method, en beide cursoronafhankelijke bronnen in de library erven ervan: TReadOnlyMappedFileStream voor lokale bestanden en TByteRangeStream voor remote bronnen die ranges serveren. De object-slice-reader vraagt één keer of zijn bron een TPDFReadAtStream is en valt terug op de oude seek-then-read-sequentie als dat niet zo is, zodat een gewone file stream of memory stream ongewijzigd blijft werken

type
  // Alleen-lezen streams waarvan absolute reads een gedeelde Seek plus Read vermijden
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Alleen-lezen toegang via een window tot één lokaal bestand
  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;

Het verschil is belangrijker dan de signature doet vermoeden. ReadAt gebruikt de offset die het krijgt en laat Position precies staan waar die stond, waardoor geneste parserniveaus reads kunnen uitvoeren zonder rond elke call een save-and-restore-dans te doen. TReadOnlyMappedFileStream implementeert nog steeds Read, Seek en Size zoals elke andere TStream, Seek begrenst de logische positie tot het bestand en Write retourneert altijd 0 omdat de bron read-only wordt geopend

Een PDF via een mapped view openen in Delphi

Twee expliciete entrypoints openen een mapped source, en geen van beide verandert het gedrag van de entrypoints die je al gebruikt. LoadFromMappedFile laadt een document en selecteert het; DAOpenMappedFile retourneert een Direct Access-handle naar hetzelfde bestand, de modus die je nodig hebt voor gigabyte-PDF's mergen en splitsen via Direct Access. LoadFromFile en DAOpenFile behouden hun file-sharing-, fout- en compatibiliteitssemantiek, zodat er niets verschuift voor callers die niet opt-in gaan. Beide mapped entrypoints nemen een aangevraagde WindowSize in bytes en een Options-bitmasker aan, en beide accepteren 0 voor elk van beide

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 kiest de standaard van 64 MiB; mapping is hier verplicht
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Uitgestelde extractie loopt nu door mapped windows in plaats van te seeken
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Wat dwingt PDF_MAPPED_FILE_REQUIRE_MAPPING precies af?

PDF_MAPPED_FILE_REQUIRE_MAPPING verandert een stille fallback in een onmiddellijke, diagnoseerbare fout tijdens het openen. Met Options op 0 accepteren beide entrypoints een read-only file-stream fallback: als het platform geen mappingcode heeft of de mappingcall faalt, wordt het document toch geopend en loopt elke read via een normale file stream. Met de flag ingesteld accepteert PDFlibPas de input alleen wanneer de eerste view tot stand is gebracht, en meldt het de weigering via LastErrorCode 401 in plaats van een document te laden dat stilletjes precies als het oude pad werkt

Op Windows opent de mapped stream een tweede read-only handle met FILE_SHARE_READ, FILE_SHARE_WRITE en FILE_SHARE_DELETE plus FILE_FLAG_RANDOM_ACCESS, maakt daarover een PAGE_READONLY-mapping en mapt de eerste window in de constructor. Eager mappen is precies het punt: een fout bij "mapping required" komt bij LoadFromMappedFile aan het licht, niet bij de eerste lazy object-read halverwege een renderjob. Het is wel belangrijk te weten waar de garantie ophoudt. De mappingcode wordt alleen voor Windows-targets gecompileerd, en een bestand van nul bytes probeert helemaal geen mapping, dus PDF_MAPPED_FILE_REQUIRE_MAPPING is een verzoek dat legitiem kan falen en geen portable belofte. Een negatieve WindowSize of een andere bit in Options dan de ene gedocumenteerde waarde wordt zonder meer geweigerd met dezelfde fout 401

Eén window, opnieuw gemapt op de allocation granularity

Er wordt altijd maar één view behouden, en daardoor blijft het gebruik van de address space onafhankelijk van de bestandsgrootte. Een WindowSize van 0 kiest 64 MiB; een waarde onder de system allocation granularity wordt daaraan gelijkgemaakt; een waarde boven 1 GiB wordt afgekapt; en het resultaat wordt naar boven afgerond op een heel aantal granularity units, 65536 bytes op Windows tenzij GetSystemInfo een andere dwAllocationGranularity meldt. Wanneer een read buiten de huidige view terechtkomt, unmapt PDFlibPas die view, lijnt de aangevraagde offset naar beneden uit op een granularity boundary en mapt daar een nieuwe window. De laatste window wordt begrensd op de fysieke bestandsgrootte, zodat de view nooit voorbij het einde van het bestand reikt

Een enkele read mag elk aantal windows doorkruisen: de loop kopieert wat de huidige view kan leveren, mapt opnieuw en gaat verder, en een request dat over het einde loopt retourneert een short count in plaats van te falen. Wat PDFlibPas bewust niet doet, is je een pointer in de view geven, want de volgende cross-window read maakt die ongeldig en geen caller kan zich daar redelijk tegen beschermen. Mapped bytes worden rechtstreeks naar parser-owned destination buffers gekopieerd, waardoor de extra file-inputbuffer en het wisselen van positie verdwijnen, maar de library claimt geen zero-copy voor de uiteindelijke parseropslag. Windowing aan de read-kant werkt ook samen met de write-kant, omdat byte-level reference shifting tijdens een snelle PDF-merge objectbytes uitstreamt terwijl de mapped source ze instreamt. De trade-off van de windowgrootte is duidelijk: een kleinere window gebruikt minder address space en mapt vaker opnieuw, wat meestal de juiste keuze is in een 32-bits proces

Wat beschermt de lock en wat meldt GetMappedFileInfo?

Één critical section beschermt de mapped view, de fallback-filecursor, de logische positie en de statistieken, en de splitsing tussen de twee readmethoden volgt daar rechtstreeks uit. ReadAt neemt de lock en roept de lockvrije interne reader aan; Read neemt dezelfde lock, roept dezelfde interne reader aan op de huidige logische positie en schuift die daarna op. De interne functie hergebruiken in plaats van de publieke ReadAt voorkomt recursieve locking, en de lock tijdens de hele copy-loop vasthouden houdt een remap van één window correct bij gelijktijdige calls. Eén Free Pascal-detail is belangrijk voordat je port: de FPC-Windows-unit declareert een eigen record met de naam TCriticalSection, dus het veld en de constructie moeten als SyncObjs.TCriticalSection worden geschreven. Delphi compileert de on-gekwalificeerde vorm probleemloos; FPC resolveert die naar een record zonder Create, Enter of 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 is false wanneer de portable file-stream fallback actief is, en het is het ene veld dat bewijst dat er nooit een mapping tot stand kwam
  • windowSize is de effectieve uitgelijnde window en niet de waarde die je hebt aangevraagd, en mappedBytes is kleiner dan die waarde in de tail-window
  • mappedOffset is het op de allocation uitgelijnde begin van de behouden view, of -1 wanneer geen view actief is
  • readCalls telt geslaagde read-requests binnen het bereik, bytesRead telt de bytes die naar callers zijn gekopieerd en remapCount omvat de initiële view

Gerichte regressies dekken absolute reads over windowgrenzen, behoud van de logische cursor, short reads aan het einde, ongeldige offsets, geweigerde writes, remapping tussen gescheiden windows, uitgestelde extractie van een niet-comprimeerbare attachment van 220 KB en ongeldig wordende statistieken na DACloseFile; de headless Win32- en Win64-suites ontdekten elk 1467 tests en slaagden allemaal zonder genegeerde, mislukte, foutgelopen of gelekte resultaten. Als je met gigabyte-PDF's werkt in Delphi of C++Builder en je profiler steeds naar file reads wijst in plaats van naar parsing, zijn de mapped-file-entrypoints een middag meten waard, en GetMappedFileInfo laat zien of je echt een mapping hebt gekregen. De volledige API-referentie en een trial build staan op de pagina van de PDFlibPas Delphi PDF library