Artykuł techniczny

Odczyt PDF przez mapowanie pamięci w Delphi: przesuwne okno

PDFlibPas może otworzyć lokalny PDF przez ograniczony, tylko do odczytu widok mapowany w pamięci: LoadFromMappedFile i DAOpenMappedFile utrzymują dokładnie jedno przesuwne okno pliku, remapują je na żądanie i dostarczają każdy fragment obiektu przez odczyty z absolutnym offsetem. Biblioteka PDF dla Delphi nigdy nie przechowuje całego źródła w pamięci, więc zużycie przestrzeni adresowej pozostaje stałe, gdy plik rośnie. Ten projekt służy jednemu obciążeniu: wielogigabajtowym PDF-om, w których parser zakończył ładowanie, ale nadal wraca do dysku obiekt po obiekcie i fragment strumienia po fragmencie strumienia

Dlaczego rozproszone odczyty pozostają kosztowne po załadowaniu PDF-a?

Załadowanie PDF-a nie kończy jego odczytu, a przy pliku wielogigabajtowym właśnie ta różnica pochłania czas. Tabela odsyłaczy albo strumień odsyłaczy (ISO 32000-1 §7.5.4 i §7.5.8) rejestruje tylko miejsce rozpoczęcia każdego obiektu pośredniego. Bajty pojawiają się później, gdy renderowana jest strona, dekodowany jest program fontu albo wyodrębniany jest osadzony strumień pliku (ISO 32000-1 §7.11.4). Archiwum o rozmiarze 2 GB z dziesiątkami tysięcy obiektów zamienia się w dziesiątki tysięcy małych, nieuporządkowanych odczytów, a podczas ładowania nie wiadomo o żadnym z nich

Dotychczasowa ścieżka tych odczytów prowadziła przez współdzielone Seek, a następnie Read na jednym strumieniu pozycyjnym, i zawodziła jednocześnie na dwa sposoby. Każdy fragment płacił za odczyt pliku, nawet gdy strona była już w pamięci podręcznej systemu operacyjnego, a kursor był współdzielonym zmiennym stanem, więc lokalny plik i źródło zakresowe za progresywnym ładowaniem zakresów PDF z prefetch nie mogły uruchamiać tego samego kodu parsera bez walki o pozycję. PDFlibPas rozwiązuje oba problemy, podnosząc odczyt z absolutnym offsetem z poziomu optymalizacji do poziomu kontraktu

Co gwarantuje TPDFReadAtStream?

TPDFReadAtStream gwarantuje odczyt z absolutnego offsetu, który ani nie zależy od logicznego kursora strumienia, ani go nie zmienia. To abstrakcyjny potomek TStream z dokładnie jedną metodą wirtualną, a oba źródła niezależne od kursora w bibliotece po nim dziedziczą: TReadOnlyMappedFileStream dla plików lokalnych i TByteRangeStream dla zdalnych źródeł obsługujących zakresy. Czytnik fragmentu obiektu raz sprawdza, czy jego źródłem jest TPDFReadAtStream, a gdy nie jest, wraca do starej sekwencji seek-then-read, więc zwykły strumień pliku albo strumień pamięci nadal działa bez zmian

type
  // Strumienie tylko do odczytu, których odczyty absolutne omijają współdzielone Seek i Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Okienkowy dostęp tylko do odczytu do jednego pliku lokalnego
  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;

Różnica jest ważniejsza, niż sugeruje sygnatura. ReadAt używa przekazanego offsetu i pozostawia Position dokładnie tam, gdzie była, dzięki czemu zagnieżdżone poziomy parsera mogą wykonywać odczyty bez zapisywania i przywracania pozycji wokół każdego wywołania. TReadOnlyMappedFileStream nadal implementuje Read, Seek i Size jak każdy inny TStream, Seek ogranicza logiczną pozycję do zakresu pliku, a Write zawsze zwraca 0, ponieważ źródło jest otwierane tylko do odczytu

Otwieranie PDF-a przez widok mapowany w Delphi

Dwa jawne punkty wejścia otwierają źródło mapowane i żaden nie zmienia zachowania używanych już punktów wejścia. LoadFromMappedFile ładuje i wybiera dokument, a DAOpenMappedFile zwraca uchwyt Direct Access do tego samego pliku — właśnie ten tryb jest potrzebny przy scalaniu i dzieleniu wielogigabajtowych PDF-ów przez Direct Access. LoadFromFile i DAOpenFile zachowują dotychczasową semantykę współdzielenia pliku, błędów i zgodności, więc nic nie zmienia się dla wywołujących, którzy nie włączą nowej ścieżki. Oba mapowane punkty wejścia przyjmują żądany WindowSize w bajtach i maskę bitową Options, a dla każdego z nich akceptują wartość 0

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 wybiera domyślne 64 MiB; mapowanie jest tutaj wymagane
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Odroczone wyodrębnianie przechodzi teraz po oknach mapowania zamiast wykonywać seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Co naprawdę wymusza PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING zamienia cichy fallback w natychmiastową, możliwą do zdiagnozowania awarię w chwili otwarcia. Przy Options pozostawionym na 0 oba punkty wejścia akceptują fallback do strumienia pliku tylko do odczytu: jeśli platforma nie ma kodu mapowania albo wywołanie mapowania się nie powiedzie, dokument nadal się otwiera, a każdy odczyt przechodzi przez zwykły strumień pliku. Po ustawieniu flagi PDFlibPas akceptuje wejście tylko wtedy, gdy utworzono pierwszy widok, i zgłasza odmowę przez LastErrorCode 401 zamiast ładować dokument, który po cichu zachowywałby się dokładnie jak stara ścieżka

W systemie Windows strumień mapowany otwiera drugi uchwyt tylko do odczytu z FILE_SHARE_READ, FILE_SHARE_WRITE i FILE_SHARE_DELETE oraz FILE_FLAG_RANDOM_ACCESS, tworzy na nim mapowanie PAGE_READONLY i mapuje pierwsze okno w konstruktorze. Wczesne wykonanie mapowania jest sednem rozwiązania: awaria typu „mapowanie wymagane” pojawia się w LoadFromMappedFile, a nie przy pierwszym leniwym odczycie obiektu w połowie zadania renderowania. Trzeba jednak jasno określić granicę gwarancji. Kod mapowania jest kompilowany wyłącznie dla celów Windows, a plik o rozmiarze zero nigdy nie próbuje mapowania, więc PDF_MAPPED_FILE_REQUIRE_MAPPING jest żądaniem, które może prawidłowo zakończyć się niepowodzeniem, a nie przenośną obietnicą. Ujemny WindowSize albo dowolny bit w Options poza jedyną udokumentowaną wartością jest od razu odrzucany z tym samym błędem 401

Jedno okno remapowane do granularity alokacji

Zawsze zachowywany jest tylko jeden widok i właśnie to uniezależnia zużycie przestrzeni adresowej od rozmiaru pliku. WindowSize równe 0 wybiera 64 MiB, wartość poniżej systemowej granularity alokacji jest podnoszona do tej granularity, wartość powyżej 1 GiB jest ograniczana, a wynik zaokrąglany w górę do całkowitej liczby jednostek granularity — na Windows 65536 bajtów, chyba że GetSystemInfo zgłosi inną wartość dwAllocationGranularity. Gdy odczyt wypada poza bieżący widok, PDFlibPas go odmapowuje, wyrównuje żądany offset w dół do granularity i mapuje tam nowe okno. Ostatnie okno jest ograniczane do fizycznego rozmiaru pliku, więc widok nigdy nie wychodzi za koniec pliku

Pojedynczy odczyt może przejść przez dowolną liczbę okien: pętla kopiuje tyle, ile dostarcza bieżący widok, remapuje go i kontynuuje, a żądanie wychodzące poza koniec zwraca skróconą liczbę bajtów zamiast kończyć się błędem. PDFlibPas celowo nie przekazuje wskaźnika do widoku, ponieważ następny odczyt przekraczający granicę okna go unieważniłby i żaden wywołujący nie mógłby rozsądnie się przed tym zabezpieczyć. Zmapowane bajty są kopiowane bezpośrednio do buforów należących do parsera, co usuwa dodatkowy bufor wejściowy pliku i przełączanie pozycji, ale biblioteka nie obiecuje zero-copy dla końcowego magazynu parsera. Okienkowanie po stronie odczytu dobrze współpracuje też z zapisem, ponieważ przesuwanie referencji bajt po bajcie podczas szybkiego scalania PDF-a wypuszcza bajty obiektów, gdy mapowane źródło je dostarcza. Kompromis rozmiaru okna jest oczywisty: mniejsze okno zajmuje mniej przestrzeni adresowej i jest częściej remapowane, co zwykle jest właściwym wyborem w procesie 32-bitowym

Co chroni blokada i co raportuje GetMappedFileInfo

Jedna sekcja krytyczna obejmuje widok mapowany, kursor pliku używany przez fallback, logiczną pozycję i statystyki, a podział między dwie metody odczytu wynika z tego wprost. ReadAt pobiera blokadę i wywołuje wewnętrzny czytnik bez blokady; Read pobiera tę samą blokadę, wywołuje ten sam wewnętrzny czytnik dla bieżącej logicznej pozycji, a następnie ją przesuwa. Ponowne użycie funkcji wewnętrznej zamiast publicznej ReadAt zapobiega rekursywnemu blokowaniu, a utrzymanie blokady przez całą pętlę kopiowania zapewnia poprawność remapowania jednego okna przy równoległych wywołaniach. Przed portowaniem warto znać jeden szczegół Free Pascala: unit FPC Windows deklaruje własny rekord o nazwie TCriticalSection, dlatego pole i jego tworzenie muszą być zapisane jako SyncObjs.TCriticalSection. Delphi bez problemu kompiluje formę bez kwalifikatora, a FPC rozwiązuje ją do rekordu bez Create, Enter ani 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 ma wartość false, gdy aktywny jest przenośny fallback do strumienia pliku, i jest jedynym polem dowodzącym, że mapowanie nigdy nie zostało ustanowione
  • windowSize to efektywne wyrównane okno, a nie wartość żądana przez użytkownika, natomiast mappedBytes jest mniejsze od niego w końcowym oknie
  • mappedOffset to wyrównany do alokacji początek zachowanego widoku albo -1, gdy żaden widok nie jest obecnie aktywny
  • readCalls zlicza pomyślne żądania odczytu mieszczące się w zakresie, bytesRead zlicza bajty skopiowane do wywołujących, a remapCount obejmuje również widok początkowy

Ukierunkowane testy regresyjne obejmują absolutne odczyty przez granicę okna, zachowanie logicznego kursora, krótkie odczyty na końcu, nieprawidłowe offsety, odrzucone zapisy, remapowanie między rozdzielonymi oknami, odroczone wyodrębnianie nieskompresowanego załącznika o rozmiarze 220 KB oraz unieważnianie statystyk po DACloseFile; bezgłowe pakiety Win32 i Win64 wykryły po 1467 testów i przeszły je wszystkie bez pominiętych, nieudanych, błędnych ani przeciekających wyników. Jeśli pracujesz z wielogigabajtowymi PDF-ami w Delphi lub C++Builder, a profiler nadal wskazuje odczyty pliku zamiast parsowania, mapowane punkty wejścia warto zmierzyć przez jedno popołudnie, a GetMappedFileInfo powie, czy rzeczywiście uzyskano mapowanie. Pełna dokumentacja API i wersja trial znajdują się na stronie biblioteki PDF dla Delphi PDFlibPas