Teknisk artikel

Memory-mapped PDF-læsning i Delphi: glidende vindue

PDFlibPas kan åbne en lokal PDF gennem en begrænset, skrivebeskyttet memory-mapped visning: LoadFromMappedFile og DAOpenMappedFile holder præcis ét glidende vindue over filen, remapper det efter behov og leverer hvert objektslice via læsninger på absolutte offsets. Delphi PDF-biblioteket holder aldrig hele kilden i hukommelsen, så brugen af address space forbliver flad, mens filen vokser. Designet er lavet til én bestemt arbejdsbelastning: gigabyte-store PDF-filer, hvor parseren er færdig med at indlæse og stadig går tilbage til disken, objekt for objekt og stream-fragment for stream-fragment

Hvorfor bliver spredte læsninger ved med at være dyre, efter PDF-filen er indlæst?

Indlæsning af en PDF betyder ikke, at læsningen er færdig, og i en fil på flere gigabyte er det netop dér, tiden forsvinder. En krydsreferencetabel eller en krydsreferencestream (ISO 32000-1 §7.5.4 og §7.5.8) registrerer kun, hvor hvert indirekte objekt starter. Bytes ankommer senere, når en side renderes, et fontprogram afkodes, eller en indlejret filstream (ISO 32000-1 §7.11.4) ekstraheres. Et 2 GB-arkiv med titusindvis af objekter bliver til titusindvis af små, uordnede læsninger, og ingen af dem er kendt på indlæsningstidspunktet

Den vej, disse læsninger tidligere tog, var en delt Seek efterfulgt af Read på én positionsbestemt stream, og den fejler i to retninger på én gang. Hvert fragment betaler for en fillæsning, selv når siden allerede ligger i operativsystemets cache, og cursoren er delt muterbar tilstand, så en lokal fil og byte-range-kilden bag progressiv PDF-range-indlæsning med prefetch ikke kunne køre den samme parserkode uden at kæmpe om positionen. PDFlibPas løser begge problemer ved at gøre læsning på absolutte offsets til en kontrakt i stedet for blot en optimering

Hvad garanterer TPDFReadAtStream?

TPDFReadAtStream garanterer en læsning på en absolut offset, som hverken afhænger af eller ændrer den logiske stream-cursor. Den er en abstrakt TStream-descendant med præcis én virtuel metode, og begge cursor-uafhængige kilder i biblioteket nedarver fra den: TReadOnlyMappedFileStream til lokale filer og TByteRangeStream til remote kilder, der leveres via ranges. Objekt-slice-læseren spørger én gang, om dens kilde er en TPDFReadAtStream, og falder tilbage til den gamle seek-efterfulgt-af-read-sekvens, når den ikke er det, så en almindelig filstream eller memorystream fortsætter med at fungere uændret

type
  // Skrivebeskyttede streams, hvis absolutte læsninger undgår en delt Seek plus Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Vinduet giver skrivebeskyttet adgang til én lokal fil
  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;

Forskellen betyder mere, end signaturen antyder. ReadAt bruger den offset, den får, og lader Position stå præcis, hvor den var, hvilket gør det muligt for indlejrede parserniveauer at udstede læsninger uden en save-and-restore-dans omkring hvert kald. TReadOnlyMappedFileStream implementerer stadig Read, Seek og Size som enhver anden TStream, Seek begrænser den logiske position til filen, og Write returnerer altid 0, fordi kilden åbnes skrivebeskyttet

Åbning af en PDF gennem en mapped view i Delphi

To eksplicitte entry points åbner en mapped-kilde, og ingen af dem ændrer adfærden for de entry points, du allerede bruger. LoadFromMappedFile indlæser og vælger et dokument; DAOpenMappedFile returnerer et Direct Access-handle over den samme fil, hvilket er den tilstand, du vil bruge ved sammenlægning og opdeling af gigabyte-store PDF-filer gennem Direct Access. LoadFromFile og DAOpenFile beholder deres fil-delings-, fejl- og kompatibilitetssemantik uændret, så intet flytter sig under kaldere, der ikke vælger mapping. Begge mapped-entry points tager en ønsket WindowSize i bytes og en Options-bitmaske, og begge accepterer 0 for hver af dem

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 vælger standarden på 64 MiB; mapping er obligatorisk her
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Udskudt ekstraktion går nu gennem mapped windows i stedet for seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Hvad håndhæver PDF_MAPPED_FILE_REQUIRE_MAPPING faktisk?

PDF_MAPPED_FILE_REQUIRE_MAPPING ændrer en lydløs fallback til en øjeblikkelig og diagnosticerbar fejl ved åbning. Når Options står på 0, accepterer begge entry points en skrivebeskyttet filstream-fallback: Hvis platformen ikke har mapping-kode, eller mapping-kaldet fejler, åbnes dokumentet stadig, og hver læsning går gennem en normal filstream. Med flaget sat accepterer PDFlibPas kun inputtet, når den første view er etableret, og rapporterer afvisningen gennem LastErrorCode 401 i stedet for at indlæse et dokument, der ubemærket opfører sig præcis som den gamle vej

På Windows åbner den mapped stream et ekstra skrivebeskyttet handle med FILE_SHARE_READ, FILE_SHARE_WRITE og FILE_SHARE_DELETE plus FILE_FLAG_RANDOM_ACCESS, opretter en PAGE_READONLY-mapping over det og mapper det første vindue i konstruktøren. Eager mapping er hele pointen: En fejl, når mapping er påkrævet, vises ved LoadFromMappedFile, ikke ved den første lazy objektlæsning halvvejs gennem et renderjob. Vær dog tydelig om, hvor garantien stopper. Mapping-koden kompileres kun til Windows-mål, og en fil på nul bytes forsøger aldrig at mappe noget, så PDF_MAPPED_FILE_REQUIRE_MAPPING er en anmodning, der legitimt kan fejle, ikke et portabelt løfte. En negativ WindowSize, eller enhver bit i Options ud over den ene dokumenterede værdi, afvises direkte med samme fejl 401

Ét vindue, remappet til allocation-granulariteten

Der beholdes kun én view ad gangen, og det er det, der holder brugen af address space uafhængig af filstørrelsen. En WindowSize på 0 vælger 64 MiB; en værdi under systemets allocation-granularitet hæves til den; en værdi over 1 GiB begrænses; og resultatet rundes op til et helt antal granularitetsenheder, 65536 bytes på Windows, medmindre GetSystemInfo rapporterer en anden dwAllocationGranularity. Når en læsning lander uden for den aktuelle view, unmapper PDFlibPas den, justerer den ønskede offset ned til en granularitetsgrænse og mapper et nyt vindue dér. Det sidste vindue begrænses til den fysiske filstørrelse, så viewet aldrig strækker sig forbi filens slutning

Én læsning kan krydse et vilkårligt antal vinduer: Løkken kopierer det, den aktuelle view kan levere, remapper og fortsætter, og en anmodning, der løber forbi slutningen, returnerer et kort antal bytes i stedet for at fejle. Det, PDFlibPas bevidst ikke gør, er at give dig en pointer ind i viewet, fordi den næste læsning på tværs af et vindue ugyldiggør den, og ingen kaldere med rimelighed kunne beskytte sig mod det. Mapped bytes kopieres direkte til parser-ejede destinationsbuffere, hvilket fjerner den ekstra fil-inputbuffer og positionsskiftene, men biblioteket fremsætter ingen zero-copy-påstand om den endelige parserlagring. Vinduesopdeling kan også kombineres med skrivesiden, eftersom bytebaseret referenceskift under en hurtig PDF-sammenlægning streamer objektbytes ud, mens den mappede kilde streamer dem ind. Afvejningen for vinduesstørrelsen er den oplagte: Et mindre vindue bruger mindre address space og remapper oftere, hvilket normalt er det rigtige valg i en 32-bit-proces

Hvad beskytter låsen, og hvad rapporterer GetMappedFileInfo?

Én kritisk sektion dækker den mappede view, fallback-filcursoren, den logiske position og statistikkerne, og opdelingen mellem de to read-metoder følger direkte af den. ReadAt tager låsen og kalder den låsefri interne læser; Read tager den samme lås, kalder den samme interne læser ved den aktuelle logiske position og flytter derefter positionen frem. At genbruge den interne funktion i stedet for den offentlige ReadAt undgår rekursiv låsning, og at holde låsen gennem hele kopieringsløkken er det, der gør en remapping af ét vindue korrekt under samtidige kald. En Free Pascal-detalje er værd at kende, før du porter: FPC-enheden Windows erklærer sin egen record med navnet TCriticalSection, så feltet og dets konstruktion skal skrives som SyncObjs.TCriticalSection. Delphi kompilerer den ukvalificerede form uden problemer; FPC opløser den til en record uden Create, Enter eller 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 er false, når den portable filstream-fallback er aktiv, og det er det ene felt, der beviser, at en mapping aldrig blev etableret
  • windowSize er det effektive, justerede vindue og ikke den værdi, du bad om, og mappedBytes er mindre end det i halevinduet
  • mappedOffset er den allocation-justerede start på den bevarede view, eller -1 når ingen view er aktiv
  • readCalls tæller vellykkede læseanmodninger inden for området, bytesRead tæller bytes, der blev kopieret til kaldere, og remapCount inkluderer den første view

Målrettede regressioner dækker absolutte læsninger på tværs af vinduer, bevarelse af den logiske cursor, korte læsninger ved halen, ugyldige offsets, afviste writes, remapping mellem adskilte vinduer, udskudt ekstraktion af en 220 KB inkomprimerbar vedhæftning og ugyldig statistik efter DACloseFile; de hovedløse Win32- og Win64-testsuiter fandt hver 1467 tests og bestod dem alle uden ignorerede, fejlede, fejlramte eller lækkede resultater. Hvis du arbejder med gigabyte-store PDF-filer i Delphi eller C++Builder, og din profiler bliver ved med at pege på fillæsninger i stedet for parsing, er de mapped-file-entry points værd en eftermiddag med målinger, og GetMappedFileInfo fortæller dig, om du faktisk fik en mapping. Den fulde API-reference og et trial-build findes på siden for PDFlibPas Delphi PDF-biblioteket