Artykuł techniczny

Strumieniowanie ogromnych PDF-ów na żądanie z PDFium w Delphi

Zeskanowane archiwum może zajmować kilka gigabajtów w jednym pliku PDF. Przeglądarka, która otwiera taki plik, zwykle chce pokazać jedną stronę, może spis treści, może stronę, do której użytkownik przeskoczył z zakładki. Wczytanie całego pliku do pamięci, aby wyrenderować dwie strony, jest marnotrawstwem na każdej płaszczyźnie: pochłania przestrzeń adresową, zatrzymuje użytkownika za długim wstępnym odczytem, a w 32-bitowym procesie Delphi może zawieść całkowicie, zanim pojawi się choćby jedna strona. PDFium zaprojektowano z myślą o tym. Potrafi załadować dokument poprzez callback, który prosi o konkretne zakresy bajtów, których potrzebuje, wtedy gdy ich potrzebuje, i nigdy nie żąda całego pliku naraz. Jedna granica należy się z góry: ten kanał strumieniowy opisuje plik 32-bitową długością, więc obsługuje pojedynczy plik do 4 GiB, co w praktyce pokrywa niemal każde zeskanowane archiwum. Plik przekraczający tę granicę nie jest przedmiotem tego artykułu; chce zostać podzielony na tomy w momencie skanowania albo otwarty poprzez strategię bezpośredniego dostępu, a zabezpieczenie, które uczciwie egzekwuje ten pułap, doczeka się własnej sekcji poniżej

Komponent udostępnia tę ścieżkę przez adapter strumienia. Przekazujesz mu dowolny TStream, a PDFium pobiera z tego strumienia bloki na żądanie. Plik może znajdować się na dysku, w polu blob bazy danych albo za dowolnym innym potomkiem TStream, i nic z tego nie jest z góry kopiowane do pamięci

Jak PDFium prosi o bajty

API C biblioteki PDFium ładuje dokument z obiektu dostarczonego przez wywołującego, opisanego strukturą FPDF_FILEACCESS. Struktura ma trzy części istotne w tym kontekście: pole długości, callback odczytu i nieprzezroczysty parametr użytkownika. Punktem wejścia, który ją konsumuje, jest FPDF_LoadCustomDocument. Gdy PDFium przejmie tę strukturę, parsuje trailer, lokalizuje tabelę odsyłaczy (cross-reference table), a od tego momentu odczytuje tylko to, czego wymaga dana operacja. Otwarcie dokumentu dotyka końcówki pliku i garstki obiektów katalogu. Renderowanie strony 400 odczytuje strumienie treści i zasoby dla tej strony i nic więcej

To jest różnica między ładowaniem buforowanym a strumieniowym. Ładowanie buforowane odczytuje plik od początku do końca, zanim PDFium zobaczy zerowy bajt. Ładowanie strumieniowe odwraca tę relację: PDFium steruje odczytami, a bajty, których nigdy się nie dotknie, nigdy nie zostaną odczytane. Dla wielogigabajtowego pliku przeglądanego strona po stronie jest to różnica między nieużywalnym ładowaniem a natychmiastowym

Adapter strumienia

Adapterem, który łączy delphijski TStream z FPDF_FILEACCESS, jest TPdfStreamAdapter. Jego konstruktor przyjmuje strumień i flagę własności, przechwytuje długość strumienia jednorazowo, wypełnia rekord FPDF_FILEACCESS i podpina callback odczytu. Gdy PDFium później wywoła zwrotnie z przesunięciem i rozmiarem, adapter ustawia pozycję strumienia na to przesunięcie i kopiuje dokładnie ten zakres do bufora dostarczonego przez PDFium

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

Flaga własności decyduje, kto zwalnia strumień. Przekaż False, a wywołujący zachowuje strumień i musi utrzymać go przy życiu przez cały okres istnienia dokumentu. Przekaż True, a adapter przejmuje go, zwalniając strumień przy zamknięciu dokumentu. Tak czy inaczej strumień musi przeżyć każdy odczyt, który wykona PDFium, ponieważ PDFium przechowuje wskaźnik FPDF_FILEACCESS i będzie wywoływał zwrotnie w dowolnym momencie, gdy dokument jest otwarty, nie tylko podczas wstępnego ładowania

Dlaczego callback jest funkcją statyczną

Callback odczytu, który PDFium przechowuje w m_GetBlock, to zwykły wskaźnik do funkcji C z konwencją wywołania cdecl. Metody Delphi nie da się użyć bezpośrednio, ponieważ metoda niesie ukryty argument Self, o którym wywołujący w C nic nie wie i którego nigdy nie dostarczy. Adapter deklaruje więc callback jako class function oznaczoną cdecl; static, co kompiluje się do samodzielnej funkcji o układzie ramki C, jakiego oczekuje PDFium, i bez niejawnego Self

To rozwiązuje kwestię konwencji wywołania, ale rodzi drugie pytanie: bez Self, jak callback dociera do konkretnego strumienia, z którego ma czytać? Odpowiedzią jest nieprzezroczysty parametr użytkownika. Gdy adapter buduje rekord, zapisuje własny wskaźnik instancji w m_Param. PDFium przekazuje ten sam wskaźnik z powrotem jako pierwszy argument każdego callbacku. Funkcja statyczna rzutuje go z powrotem na TPdfStreamAdapter i kieruje odczyt do strumienia tej instancji. Jest to standardowa trampolina do przekazywania kontekstu obiektu przez granicę C, która nie ma pojęcia o obiektach

// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recover the instance from m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // report failure by return value, never by raising
  end;
end;

Pułap 4 GiB i dlaczego wymaga zabezpieczenia

Stąd bierze się granica wskazana we wstępie. Pole długości m_FileLen w FPDF_FILEACCESS to 32-bitowa wartość bez znaku. Jego największa reprezentowalna długość jest o jeden bajt mniejsza od 4 GiB. TStream raportuje swój rozmiar jako Int64, więc strumień może opisać znacznie więcej bajtów, niż to pole zdoła pomieścić. W chwili, gdy rozmiar strumienia przekroczy ten pułap, nie ma uczciwego sposobu, by powiedzieć PDFium, jak długi jest plik

Błędną reakcją jest przypisanie rozmiaru i pozwolenie mu na zawinięcie (wrap). Obcięcie długości 5 GiB do 32-bitowego pola daje małą, wiarygodnie wyglądającą liczbę, a PDFium wtedy sparsuje plik w przekonaniu, że kończy się on mniej więcej po gigabajcie. Trailer i tabela odsyłaczy żyją na prawdziwym końcu pliku, daleko za obciętą długością, więc parsowanie zawodzi w sposób, który nie ma nic wspólnego z rzeczywistą przyczyną. Debugowałbyś błąd odsyłaczy na pliku, który jest w pełni poprawny, bez cienia wskazówki, że dwie warstwy wyżej zawinęła się liczba całkowita

Adapter zamiast tego odrzuca dane wejściowe. Konstruktor porównuje rozmiar strumienia z High(FPDF_DWORD) i zgłasza EPdfError w momencie, gdy strumień jest zbyt duży, by go opisać. Jawny, natychmiastowy błąd nazywa prawdziwy problem w miejscu konstrukcji. Ciche obcięcie ukrywa go za mylącym objawem, który goniłbyś znacznie później. Limit 4 GiB to autentyczne ograniczenie tej ścieżki ładowania, a uczciwą rzeczą jest ujawnienie go głośno, zamiast zamaskowania arytmetyką, która akurat się kompiluje. Gdy archiwum naprawdę przekracza granicę, obiecane na wstępie środki zaradcze leżą poza tym API: podziel skan na pliki per-tom, z których każdy pozostaje pod pułapem, albo pozostaw dokument na dysku i obsłuż go przez projekt bezpośredniego dostępu zbudowany na 64-bitowych przesunięciach, a nie przez FPDF_FILEACCESS

Błędy nie mogą przekraczać granicy

Odczyt może zawieść. Strumień może być obiektem opartym na sieci, który przekracza limit czasu, uchwytem blob, który zamknięto pod tobą, albo plikiem obciętym po otwarciu dokumentu. Kontrakt PDFium dla callbacku odczytu to wartość zwracana: niezerowa dla sukcesu, zerowa dla niepowodzenia. To ramka C i nie ma mechaniki do przechwytywania ani propagowania wyjątku Pascala

Dlatego trampolina owija seek i odczyt w blok try/except, który połyka wyjątek i zwraca zero. Gdyby wyjątkowi Delphi pozwolono propagować się poza callback, rozwinąłby się przez ramki stosu cdecl PDFium, które nigdy nie były budowane po to, by rozwijała je mechanika wyjątków Pascala. Rezultatem jest w najlepszym razie zachowanie niezdefiniowane, a w najgorszym twarde zawieszenie, głęboko wewnątrz parsera PDF bez użytecznego stosu. Zwrócenie zera utrzymuje niepowodzenie w ramach kontraktu. PDFium widzi nieudany odczyt bloku, czysto przerywa operację, a FPDF_LoadCustomDocument raportuje, że dokumentu nie dało się załadować, co komponent ujawnia jako EPdfError po stronie Pascala, gdzie jest jego miejsce

Otwieranie dokumentu w ten sposób

Metodą komponentu, która steruje ścieżką strumieniową, jest LoadCustomDocument, zadeklarowana jako odrębna metoda, a nie kolejne przeciążenie LoadDocument, tak aby przekazanie TMemoryStream nigdy przypadkiem nie trafiło na ścieżkę buforowaną. Buduje adapter, wywołuje FPDF_LoadCustomDocument i utrzymuje adapter przy życiu przez cały okres istnienia załadowanego dokumentu

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

To samo wywołanie działa dla TMemoryStream, strumienia blob z zestawu danych bazy albo niestandardowego potomka TStream. Ładowanie na żądanie zarabia na siebie, gdy plik jest duży, a odczytana zostanie tylko jego część: przeglądarka archiwum, generator miniatur próbkujący kilka stron, indeks wyszukiwania pobierający po jednej stronie naraz. Gdy plik jest mały albo i tak zamierzasz go cały odczytać, ładowanie buforowane jest prostsze, a mechanika strumieniowa nic ci nie daje. Czynnikiem rozstrzygającym jest stosunek bajtów, których faktycznie dotkniesz, do bajtów, które plik zawiera

Gdy strony już strumieniują się na żądanie, kolejnym zmartwieniem jest utrzymanie responsywności renderowanych stron, gdy użytkownik powiększa i przewija, co omawia nasza notatka o buforowaniu renderowania i wydajności powiększania. Gdy strumieniowany dokument jest taki, który przeglądarka powinna wyświetlać, ale nie pozwalać użytkownikowi na eksport ani zmiany, techniki z przewodnika po bezpiecznym podglądzie PDF łączą się naturalnie z tą ścieżką ładowania. Obie opierają się na opisanym tu ładowaniu strumieniowym, które jest dostarczane jako część PDFium Component dla Delphi i C++Builder, obok API renderowania, wyodrębniania tekstu i adnotacji omawianych gdzie indziej na tym blogu