Tehnički članak

Memorijsko mapiranje PDF-a u Delphiju: klizni prozor

PDFlibPas može otvoriti lokalni PDF kroz ograničeni memorijski mapirani prikaz samo za čitanje: LoadFromMappedFile i DAOpenMappedFile održavaju točno jedan klizni prozor nad datotekom, po potrebi ga ponovno mapiraju i svaki odsječak objekta isporučuju čitanjem s apsolutnim pomakom. Delphi PDF biblioteka nikad ne drži cijeli izvor u memoriji, pa upotreba adresnog prostora ostaje ravna kako datoteka raste. Dizajn postoji za jedno radno opterećenje: gigabajtne PDF-ove kod kojih je parser završio početno učitavanje, ali se još vraća na disk, objekt po objekt i fragment toka po fragment toka

Zašto rijetka čitanja ostaju skupa nakon učitavanja PDF-a?

Učitavanje PDF-a ne znači da je čitanje završeno, a kod višegigabajtne datoteke upravo ta razlika troši vrijeme. Tablica unakrsnih referenci ili tok unakrsnih referenci (ISO 32000-1 §7.5.4 i §7.5.8) samo bilježi gdje počinje svaki neizravni objekt. Bajtovi stižu kasnije, kada se stranica iscrtava, kada se dekodira program fonta ili kada se izdvaja tok ugrađene datoteke (ISO 32000-1 §7.11.4). Arhiva od 2 GB s desecima tisuća objekata postaje desecima tisuća malih, neuređenih čitanja, a nijedno od njih nije poznato u trenutku učitavanja

Ta su čitanja prije išla kroz zajednički Seek, a zatim Read nad jednim pozicijskim tokom, i taj put zakazuje u dva smjera odjednom. Svaki fragment plaća čitanje datoteke čak i kada je stranica već prisutna u predmemoriji operacijskog sustava, a pokazivač je zajedničko promjenjivo stanje, pa lokalna datoteka i izvor raspona iza progresivnog učitavanja PDF raspona s prefetchom nisu mogli pokrenuti isti parserni kod bez borbe za poziciju. PDFlibPas rješava oba problema tako što čitanje s apsolutnim pomakom od optimizacije pretvara u ugovor

Što jamči TPDFReadAtStream?

TPDFReadAtStream jamči čitanje s apsolutnog pomaka koje ni ovisi o logičkom pokazivaču toka ni ga mijenja. To je apstraktni potomak TStream s točno jednom virtualnom metodom, a oba izvora u biblioteci neovisna o pokazivaču izvedena su iz njega: TReadOnlyMappedFileStream za lokalne datoteke i TByteRangeStream za udaljene izvore poslužene po rasponima. Čitač odsječaka objekata jednom provjeri je li njegov izvor TPDFReadAtStream i vraća se na stari slijed seek-pa-read kada nije, pa obični tok datoteke ili memorijski tok nastavlja raditi bez promjene

type
  // Tokovi samo za čitanje čija apsolutna čitanja izbjegavaju zajednički Seek i Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Prozorni pristup samo za čitanje jednoj lokalnoj datoteci
  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;

Razlika je važnija nego što potpis sugerira. ReadAt koristi predani pomak i ostavlja Position točno ondje gdje je bio, što omogućuje ugniježđenim razinama parsera da izdaju čitanja bez spremanja i vraćanja položaja oko svakog poziva. TReadOnlyMappedFileStream i dalje implementira Read, Seek i Size kao bilo koji drugi TStream, Seek ograničava logičku poziciju unutar datoteke, a Write uvijek vraća 0 jer je izvor otvoren samo za čitanje

Otvaranje PDF-a kroz mapirani prikaz u Delphiju

Dvije izričite ulazne točke otvaraju mapirani izvor i nijedna ne mijenja ponašanje ulaznih točaka koje već koristite. LoadFromMappedFile učitava i odabire dokument; DAOpenMappedFile vraća Direct Access ručku nad istom datotekom, a to je način koji želite kada spajate i dijelite gigabajtne PDF-ove kroz Direct Access. LoadFromFile i DAOpenFile zadržavaju vlastitu semantiku dijeljenja datoteka, pogrešaka i kompatibilnosti, pa se pozivateljima koji se ne odluče za mapiranje ništa ne mijenja. Obje mapirane ulazne točke primaju zatraženi WindowSize u bajtovima i bitmasku Options, a za bilo koji od ta dva argumenta prihvaćaju 0

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 odabire zadanu vrijednost od 64 MiB; mapiranje je ovdje obavezno
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Odgođeno izdvajanje sada prolazi kroz mapirane prozore umjesto seekanja
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Što PDF_MAPPED_FILE_REQUIRE_MAPPING zapravo prisiljava?

PDF_MAPPED_FILE_REQUIRE_MAPPING tihi fallback pretvara u trenutačan i dijagnosticiran neuspjeh pri otvaranju. Kada je Options jednak 0, obje ulazne točke prihvaćaju fallback na tok datoteke samo za čitanje: ako platforma nema kod za mapiranje ili poziv mapiranja ne uspije, dokument se ipak otvara i svako čitanje prolazi kroz obični tok datoteke. Kada je zastavica postavljena, PDFlibPas prihvaća ulaz samo ako je prvi prikaz uspostavljen i odbijanje prijavljuje kroz LastErrorCode 401, umjesto da učita dokument koji tiho radi upravo kao stari put

Na Windowsu mapirani tok otvara drugu ručku samo za čitanje sa FILE_SHARE_READ, FILE_SHARE_WRITE i FILE_SHARE_DELETE, uz FILE_FLAG_RANDOM_ACCESS, nad njom stvara mapiranje PAGE_READONLY i prvi prozor mapira već u konstruktoru. Rano mapiranje cijela je svrha: neuspjeh zahtjeva za mapiranjem pojavljuje se u LoadFromMappedFile, a ne pri prvom lijenom čitanju objekta na polovici posla iscrtavanja. Ipak, budite jasni gdje jamstvo prestaje. Kod mapiranja preveden je samo za ciljeve Windowsa, a datoteka od nula bajtova uopće ne pokušava mapiranje, pa je PDF_MAPPED_FILE_REQUIRE_MAPPING zahtjev koji legitimno može ne uspjeti, a ne prenosivo obećanje. Negativan WindowSize, ili bilo koji bit u Options osim jedine dokumentirane vrijednosti, izravno se odbija istom pogreškom 401

Jedan prozor, ponovno mapiran prema granularnosti alokacije

Zadržava se samo jedan prikaz i upravo to upotrebu adresnog prostora čini neovisnom o veličini datoteke. WindowSize 0 odabire 64 MiB; vrijednost manja od sistemske granularnosti alokacije podiže se do nje; vrijednost iznad 1 GiB ograničava se na 1 GiB; a rezultat se zaokružuje na cijeli broj jedinica granularnosti, 65536 bajtova u Windowsu, osim ako GetSystemInfo ne prijavi drugačiji dwAllocationGranularity. Kada čitanje padne izvan trenutačnog prikaza, PDFlibPas ga odmapira, poravna zatraženi pomak prema dolje na granicu granularnosti i ondje mapira novi prozor. Posljednji prozor ograničava se fizičkom veličinom datoteke, pa se prikaz nikad ne proteže iza njezina kraja

Jedno čitanje može prijeći preko proizvoljnog broja prozora: petlja kopira sve što trenutačni prikaz može isporučiti, ponovno mapira i nastavlja, a zahtjev koji prijeđe kraj vraća kraći broj bajtova umjesto neuspjeha. PDFlibPas namjerno ne predaje pokazivač u prikaz jer ga sljedeće čitanje preko granice prozora čini nevažećim i nijedan se pozivatelj razumno ne bi mogao od toga zaštititi. Mapirani bajtovi kopiraju se izravno u odredišne međuspremnike u vlasništvu parsera, čime nestaju dodatni ulazni međuspremnik datoteke i prebacivanje pozicije, ali biblioteka ne tvrdi da je konačna pohrana parsera bez kopiranja. Prozorstvo pri čitanju slaže se i sa stranom za pisanje, jer pomicanje referenci na razini bajtova tijekom brzog spajanja PDF-a šalje bajtove objekta iz mapiranog izvora dok ih druga strana prima. Razmjena veličine prozora očita je: manji prozor zauzima manje adresnog prostora i češće se ponovno mapira, što je obično pravi izbor unutar 32-bitnog procesa

Što štiti brava i što prijavljuje GetMappedFileInfo

Jedan kritični odsječak obuhvaća mapirani prikaz, rezervni pokazivač datoteke, logičku poziciju i statistiku, a razlika između dviju metoda čitanja izravno proizlazi iz toga. ReadAt uzima bravu i poziva internog čitača bez brave; Read uzima istu bravu, poziva tog čitača na trenutačnoj logičkoj poziciji i zatim je pomiče. Ponovna upotreba interne funkcije umjesto javnog ReadAt izbjegava rekurzivno zaključavanje, a držanje brave kroz cijelu petlju kopiranja čuva ispravnost ponovnog mapiranja jednog prozora pri istodobnim pozivima. Prije porta vrijedi znati jedan detalj Free Pascala: FPC jedinica Windows deklarira vlastiti zapis imena TCriticalSection, pa polje i njegova izgradnja moraju biti napisani kao SyncObjs.TCriticalSection. Delphi rado prevodi nekvalificirani oblik; FPC ga razrješava u zapis bez Create, Enter ili 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 kada je aktivan prenosivi fallback na tok datoteke, a to je jedino polje koje dokazuje da mapiranje nikad nije uspostavljeno
  • windowSize je efektivni poravnati prozor, a ne vrijednost koju ste zatražili, dok je mappedBytes u završnom prozoru manji od njega
  • mappedOffset je početak zadržanog prikaza poravnat prema alokaciji, ili -1 kada trenutačno nije aktivan nijedan prikaz
  • readCalls broji uspješne zahtjeve za čitanjem unutar raspona, bytesRead broji bajtove kopirane pozivateljima, a remapCount uključuje početni prikaz

Ciljane regresije pokrivaju apsolutna čitanja preko granica prozora, očuvanje logičkog pokazivača, kratka čitanja na kraju, nevažeće pomake, odbijena pisanja, ponovno mapiranje između odvojenih prozora, odgođeno izdvajanje nekompresiranog privitka od 220 KB i statistiku koja postaje nevažeća nakon DACloseFile; headless paketi Win32 i Win64 svaki su otkrili 1467 testova i svi su prošli bez preskočenih, neuspjelih, neispravnih ili procurjelih rezultata. Ako radite s gigabajtnim PDF-ovima u Delphiju ili C++Builderu, a profiler vam stalno pokazuje čitanja datoteka umjesto parsiranja, mapirane ulazne točke vrijedi jedno poslijepodne mjeriti, a GetMappedFileInfo reći će vam jeste li doista dobili mapiranje. Puna referenca API-ja i probna izgradnja nalaze se na stranici PDFlibPas Delphi PDF library