Technischer Artikel

Memory-Mapped-PDF-Lesen in Delphi: Schiebefenster

PDFlibPas kann ein lokales PDF über eine begrenzte, schreibgeschützte Memory-Mapped-Ansicht öffnen: LoadFromMappedFile und DAOpenMappedFile halten genau ein gleitendes Fenster über der Datei, ordnen es bei Bedarf neu zu und liefern jeden Objektausschnitt über Lesezugriffe mit absolutem Offset. Die Delphi-PDF-Bibliothek hält nie die gesamte Quelle im Speicher, daher bleibt die Nutzung des Adressraums auch bei wachsender Datei konstant. Das Design ist für genau einen Workload gedacht: Gigabyte-PDFs, deren Parser das Laden beendet hat und anschließend noch Objekt für Objekt und Streamfragment für Streamfragment auf die Platte zurückgreift

Warum bleiben verstreute Lesezugriffe teuer, obwohl das PDF geladen ist?

Das Laden eines PDFs beendet das Lesen nicht, und bei einer mehrere Gigabyte großen Datei steckt genau dort die Zeit. Eine Querverweistabelle oder ein Querverweis-Stream (ISO 32000-1 §7.5.4 und §7.5.8) verzeichnet nur, wo jedes indirekte Objekt beginnt. Die Bytes kommen später, wenn eine Seite gerendert, ein Font-Programm dekodiert oder ein eingebetteter Datei-Stream (ISO 32000-1 §7.11.4) extrahiert wird. Ein 2-GB-Archiv mit Zehntausenden Objekten wird so zu Zehntausenden kleiner, ungeordneter Lesezugriffe, und beim Laden ist keiner davon bekannt

Diese Lesezugriffe liefen früher über ein gemeinsames Seek gefolgt von Read auf einem Positions-Stream, und das scheitert in zwei Richtungen zugleich. Jedes Fragment bezahlt für einen Dateizugriff, selbst wenn die Seite bereits im Cache des Betriebssystems liegt, und der Cursor ist gemeinsamer veränderlicher Zustand. Deshalb konnten eine lokale Datei und die Byte-Range-Quelle hinter progressivem PDF-Range-Laden mit Prefetch nicht denselben Parsercode ausführen, ohne um die Position zu kämpfen. PDFlibPas behebt beides, indem es das Lesen über absolute Offsets von einer Optimierung zu einem Vertrag macht

Was garantiert TPDFReadAtStream?

TPDFReadAtStream garantiert ein Lesen an einem absoluten Offset, das weder vom logischen Stream-Cursor abhängt noch ihn verändert. Es ist ein abstrakter TStream-Nachkomme mit genau einer virtuellen Methode, und beide cursorunabhängigen Quellen der Bibliothek leiten sich davon ab: TReadOnlyMappedFileStream für lokale Dateien und TByteRangeStream für per Range ausgelieferte entfernte Quellen. Der Leser für Objektausschnitte fragt einmal ab, ob seine Quelle ein TPDFReadAtStream ist, und fällt andernfalls auf die alte Seek-dann-Read-Sequenz zurück, sodass ein gewöhnlicher Datei- oder Memory-Stream unverändert weiterarbeitet

type
  // Schreibgeschützte Streams, deren absolute Lesezugriffe ein gemeinsames Seek plus Read vermeiden
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Fensterzugriff auf genau eine lokale Datei, nur zum Lesen
  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;

Der Unterschied ist wichtiger, als die Signatur vermuten lässt. ReadAt verwendet den übergebenen Offset und lässt Position exakt dort, wo sie war. So können verschachtelte Parser-Ebenen Lesezugriffe ausführen, ohne jeden Aufruf mit einer Save-and-Restore-Schleife zu umgeben. TReadOnlyMappedFileStream implementiert weiterhin Read, Seek und Size wie jeder andere TStream, Seek begrenzt die logische Position auf die Datei, und Write liefert immer 0, weil die Quelle schreibgeschützt geöffnet wird

Ein PDF in Delphi über eine gemappte Ansicht öffnen

Zwei ausdrückliche Einstiegspunkte öffnen eine gemappte Quelle, und keiner ändert das Verhalten der bereits verwendeten Einstiegspunkte. LoadFromMappedFile lädt ein Dokument und wählt es aus; DAOpenMappedFile liefert ein Direct-Access-Handle über derselben Datei. Dieser Modus ist der richtige, wenn Sie Gigabyte-PDFs über Direct Access zusammenführen und teilen. LoadFromFile und DAOpenFile behalten ihre Semantik für Dateifreigabe, Fehler und Kompatibilität unverändert, sodass sich für Aufrufer ohne Opt-in nichts verschiebt. Beide gemappten Einstiegspunkte nehmen ein angefordertes WindowSize in Bytes und eine Options-Bitmaske an, und für beide ist 0 zulässig

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 wählt den Standardwert 64 MiB; Mapping ist hier zwingend
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Die verzögerte Extraktion durchläuft nun gemappte Fenster statt zu seeken
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Was erzwingt PDF_MAPPED_FILE_REQUIRE_MAPPING tatsächlich?

PDF_MAPPED_FILE_REQUIRE_MAPPING macht aus einem stillen Fallback einen sofortigen, diagnostizierbaren Fehler beim Öffnen. Wenn Options auf 0 bleibt, akzeptieren beide Einstiegspunkte einen schreibgeschützten File-Stream-Fallback: Hat die Plattform keinen Mapping-Code oder schlägt der Mapping-Aufruf fehl, wird das Dokument trotzdem geöffnet und jeder Zugriff läuft über einen normalen File-Stream. Ist das Flag gesetzt, akzeptiert PDFlibPas die Eingabe nur, wenn die erste View eingerichtet wurde, und meldet die Zurückweisung über LastErrorCode 401, statt ein Dokument zu laden, das unbemerkt genau wie der alte Pfad arbeitet

Unter Windows öffnet der gemappte Stream ein zweites schreibgeschütztes Handle mit FILE_SHARE_READ, FILE_SHARE_WRITE und FILE_SHARE_DELETE sowie FILE_FLAG_RANDOM_ACCESS, erstellt darüber ein PAGE_READONLY-Mapping und mappt im Konstruktor das erste Fenster. Das frühe Mapping ist der ganze Zweck: Ein Fehler bei "Mapping erforderlich" wird bei LoadFromMappedFile sichtbar und nicht erst beim ersten verzögerten Objektzugriff mitten in einem Renderauftrag. Allerdings sollte man genau wissen, wo die Garantie endet. Der Mapping-Code wird nur für Windows-Ziele kompiliert, und eine Datei mit null Bytes versucht überhaupt kein Mapping. PDF_MAPPED_FILE_REQUIRE_MAPPING ist daher eine Anforderung, die berechtigt scheitern kann, und kein portables Versprechen. Ein negatives WindowSize oder jedes andere Bit in Options außer dem dokumentierten Wert wird mit demselben Fehler 401 direkt zurückgewiesen

Ein Fenster, auf Granularitätsgrenzen neu gemappt

Es bleibt immer nur eine View erhalten, und dadurch hängt die Nutzung des Adressraums nicht von der Dateigröße ab. Ein WindowSize von 0 wählt 64 MiB; ein Wert unterhalb der System-Allokationsgranularität wird auf diese angehoben; ein Wert über 1 GiB wird gekappt; anschließend wird auf eine ganze Anzahl von Granularitätseinheiten aufgerundet, unter Windows 65536 Bytes, sofern GetSystemInfo nicht einen anderen Wert für dwAllocationGranularity meldet. Wenn ein Lesezugriff außerhalb der aktuellen View landet, hebt PDFlibPas sie auf, richtet den angeforderten Offset nach unten an einer Grenze aus und mappt dort ein frisches Fenster. Das letzte Fenster wird auf die physische Dateigröße begrenzt, sodass die View niemals über das Dateiende hinausreicht

Ein einzelner Lesezugriff darf beliebig viele Fenster überschreiten: Die Schleife kopiert, was die aktuelle View liefern kann, mappt neu und fährt fort; ein Zugriff über das Ende hinaus liefert eine kürzere Anzahl statt eines Fehlers. PDFlibPas tut bewusst nicht so, als würde es Ihnen einen Zeiger in die View geben, denn der nächste Zugriff über eine Fenstergrenze macht ihn ungültig und kein Aufrufer könnte sich vernünftig dagegen schützen. Gemappte Bytes werden direkt in parser-eigene Zielpuffer kopiert. Das entfernt den zusätzlichen Datei-Eingabepuffer und das Umschalten der Position, aber die Bibliothek verspricht für den endgültigen Parser-Speicher kein Zero-Copy. Fensterlesen auf der Eingabeseite lässt sich auch mit der Ausgabeseite verbinden, denn das Verschieben von Referenzen auf Byteebene bei einem schnellen PDF-Merge schreibt Objektbytes aus, während die gemappte Quelle sie einliest. Der Kompromiss bei der Fenstergröße ist offensichtlich: Ein kleineres Fenster belegt weniger Adressraum und wird häufiger neu gemappt, was in einem 32-Bit-Prozess normalerweise die richtige Entscheidung ist

Was der Lock schützt und was GetMappedFileInfo meldet

Eine Critical Section umfasst die gemappte View, den Cursor der Fallback-Datei, die logische Position und die Statistiken, und die Aufteilung zwischen den beiden Lesemethoden ergibt sich direkt daraus. ReadAt nimmt den Lock und ruft den lockfreien internen Leser auf; Read nimmt denselben Lock, ruft den internen Leser an der aktuellen logischen Position auf und rückt sie anschließend weiter. Die Wiederverwendung der internen Funktion statt des öffentlichen ReadAt verhindert rekursives Locking, und das Halten des Locks über die gesamte Kopierschleife hält ein Remapping eines einzigen Fensters auch bei konkurrierenden Aufrufen korrekt. Ein Free-Pascal-Detail sollte man vor dem Portieren kennen: Die FPC-Unit Windows deklariert einen eigenen Record namens TCriticalSection, daher müssen Feld und Konstruktion als SyncObjs.TCriticalSection geschrieben werden. Delphi kompiliert die unqualifizierte Form problemlos; FPC löst sie zu einem Record ohne Create, Enter oder Leave auf

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 ist false, sobald der portable File-Stream-Fallback aktiv ist, und es ist das einzige Feld, das beweist, dass nie ein Mapping eingerichtet wurde
  • windowSize ist das effektiv ausgerichtete Fenster und nicht der angeforderte Wert, während mappedBytes im letzten Fenster kleiner sein kann
  • mappedOffset ist der an der Allokationsgranularität ausgerichtete Anfang der behaltenen View oder -1, wenn momentan keine View aktiv ist
  • readCalls zählt erfolgreiche Leseanforderungen innerhalb des Bereichs, bytesRead die an Aufrufer kopierten Bytes und remapCount umfasst die erste View

Gezielte Regressionen decken absolute Lesezugriffe über Fenstergrenzen, die Erhaltung des logischen Cursors, kurze Lesezugriffe am Ende, ungültige Offsets, zurückgewiesene Schreibzugriffe, Remappings zwischen getrennten Fenstern, die verzögerte Extraktion eines 220-KB-Anhangs ohne Kompression und ungültige Statistiken nach DACloseFile ab. Die kopflosen Win32- und Win64-Suiten entdeckten jeweils 1467 Tests und bestanden sie alle ohne ignorierte, fehlgeschlagene, fehlerhafte oder undichte Ergebnisse. Wenn Sie in Delphi oder C++Builder mit Gigabyte-PDFs arbeiten und Ihr Profiler weiterhin auf Dateizugriffe statt auf Parsing zeigt, sind die Mapped-File-Einstiegspunkte einen Messtag wert, und GetMappedFileInfo sagt Ihnen, ob Sie tatsächlich ein Mapping erhalten haben. Die vollständige API-Referenz und ein Trial-Build stehen auf der Seite der PDFlibPas Delphi PDF Library