Technical Article

Strumieniowanie ogromnych plików PDF na żądanie za pomocą PDFium w Delphi

Zeskanowane archiwum może z łatwością osiągnąć rozmiar kilku gigabajtów w pojedynczym pliku PDF. Przeglądarka otwierająca taki plik zazwyczaj chce wyświetlić jedną stronę – być może spis treści, być może stronę, do której użytkownik przeszedł z zakładki. Odczytywanie całego pliku do pamięci w celu wyrenderowania dwóch stron jest marnotrawstwem pod każdym względem: zużywa przestrzeń adresową, blokuje użytkownika za długim początkowym odczytem, a w 32-bitowym procesie Delphi może zakończyć się niepowodzeniem przed pojawieniem się choćby jednej strony. Projektanci PDFium mieli to na uwadze. Biblioteka potrafi załadować dokument poprzez wywołanie zwrotne żądające konkretnych zakresów bajtów, których potrzebuje w danym momencie, i nigdy nie wymaga całego pliku naraz.

Komponent udostępnia tę ścieżkę poprzez adapter strumienia. Przekazujesz do niego dowolny obiekt TStream, a PDFium pobiera bloki z tego strumienia na żądanie. Plik może znajdować się na dysku, w polu blob bazy danych lub za dowolnym innym obiektem pochodnym TStream, a żadna jego część nie jest kopiowana z góry do pamięci.

Jak PDFium pyta o bajty

Interfejs API C biblioteki PDFium ładuje dokument z obiektu dostarczonego przez wywołującego, opisanego przez strukturę FPDF_FILEACCESS. Struktura ta składa się z trzech części: pola długości, wywołania zwrotnego odczytu (read callback) i nieprzezroczystego parametru użytkownika. Punktem wejściowym, który ją konsumuje, jest funkcja FPDF_LoadCustomDocument. Gdy PDFium posiada już tę strukturę, analizuje trailer, lokalizuje tabelę odsyłaczy skrośnych i od tej pory czyta tylko to, czego wymaga dana operacja. Otwarcie dokumentu dotyka końca pliku i garści obiektów katalogu. Renderowanie strony 400 odczytuje strumienie zawartości i zasoby dla tej strony – i nic więcej.

To jest właśnie różnica między ładowaniem buforowanym a ładowaniem strumieniowym. Ładowanie buforowane odczytuje plik od początku do końca, zanim PDFium w ogóle zobaczy bajt zero. Ładowanie strumieniowe odwraca tę relację: to PDFium steruje odczytami, a bajty, które nie zostaną dotknięte, nigdy nie zostaną odczytane. W przypadku wielogigabajtowego pliku przeglądanego strona po stronie jest to różnica między bezużytecznym ładowaniem a ładowaniem natychmiastowym.

Adapter strumienia

Adapterem łączącym obiekt TStream z Delphi ze strukturą FPDF_FILEACCESS jest TPdfStreamAdapter. Jego konstruktor przyjmuje strumień oraz flagę własności, jednorazowo pobiera długość strumienia, wypełnia rekord FPDF_FILEACCESS i konfiguruje wywołanie zwrotne odczytu. Gdy PDFium później wywołuje funkcję 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 o tym, kto zwalnia strumień. Przekaż False, a wywołujący zachowa strumień i musi utrzymać go przy życiu przez cały czas użytkowania dokumentu. Przekaż True, a adapter przejmie go, zwalniając strumień po zamknięciu dokumentu. W obu przypadkach strumień musi przeżyć każdy odczyt wykonywany przez PDFium, ponieważ PDFium przechowuje wskaźnik FPDF_FILEACCESS i wywoła go w dowolnym momencie, gdy dokument jest otwarty, a nie tylko podczas początkowego ładowania.

Dlaczego wywołanie zwrotne to funkcja statyczna

Wywołanie zwrotne odczytu zapisane przez PDFium w m_GetBlock to zwykły wskaźnik funkcji C z konwencją wywoływania cdecl. Metoda Delphi nie może być użyta bezpośrednio, ponieważ niesie ze sobą ukryty argument Self, o którym wywołujący C nic nie wie i którego nigdy nie dostarczy. Adapter deklaruje zatem wywołanie zwrotne jako class function oznaczoną jako cdecl; static, co kompiluje się do niezależnej funkcji z układem ramki C, jakiego oczekuje PDFium, bez ukrytego argumentu Self.

To rozwiązuje kwestię konwencji wywoływania, ale rodzi drugie pytanie: skoro nie ma argumentu Self, to w jaki sposób wywołanie zwrotne dociera do konkretnego strumienia, z którego ma czytać? Odpowiedzią jest nieprzezroczysty parametr użytkownika. Gdy adapter buduje rekord, zapisuje wskaźnik do własnej instancji w m_Param. PDFium przekazuje ten sam wskaźnik z powrotem jako pierwszy argument każdego wywołania zwrotnego. Funkcja statyczna rzutuje go z powrotem na TPdfStreamAdapter i wysyła żądanie odczytu do strumienia tej instancji. Jest to standardowy mechanizm 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;

Sufit 4 GiB i dlaczego wymaga zabezpieczenia

Pole długości m_FileLen w strukturze FPDF_FILEACCESS to 32-bitowa wartość bez znaku. Jej największy reprezentowany rozmiar to jeden bajt poniżej 4 GiB. Obiekt TStream raportuje swój rozmiar jako Int64, więc strumień może opisywać znacznie więcej bajtów, niż to pole jest w stanie pomieścić. W momencie, gdy rozmiar strumienia przekroczy ten sufit, nie ma uczciwego sposobu, aby poinformować PDFium o długości pliku.

Błędną reakcją byłoby przypisanie rozmiaru i pozwolenie na jego zawinięcie. Obcięcie długości 5 GiB do pola 32-bitowego daje małą, pozornie poprawną liczbę, a PDFium przeanalizuje plik wierząc, że kończy się on w okolicach pierwszego gigabajta. Trailer i tabela odsyłaczy skrośnych znajdują się na samym końcu pliku, daleko za obciętą długością, więc analiza kończy się niepowodzeniem w sposób, który nie ma nic wspólnego z rzeczywistą przyczyną. Debugowałbyś błąd odsyłaczy skrośnych w pliku, który jest całkowicie poprawny, bez żadnej wskazówki, że dwie warstwy wyżej zawinęła się liczba całkowita.

Zamiast tego adapter odrzuca takie wejście. Konstruktor porównuje rozmiar strumienia z wartością High(FPDF_DWORD) i zgłasza błąd EPdfError w momencie, gdy strumień jest zbyt duży, by go opisać. Wyraźny, natychmiastowy błąd wskazuje prawdziwy problem w punkcie konstrukcji. Ciche obcięcie ukrywa go za mylącym objawem, który analizowałbyś znacznie później. Ograniczenie do 4 GiB to rzeczywiste uwarunkowanie tej ścieżki ładowania i uczciwą rzeczą jest głośne zasygnalizowanie tego problemu, zamiast tuszowania go działaniami arytmetycznymi, które przypadkowo się kompilują.

Awarie nie mogą przekraczać granicy biblioteki

Odczyt może się nie udać. Strumień może być obiektem sieciowym, który ulega przedawnieniu, uchwytem bloba bazy danych, który został zamknięty pod spodem, lub plikiem, który został skrócony po otwarciu dokumentu. Kontraktem PDFium dla wywołania zwrotnego odczytu jest wartość zwracana: niezerowa dla sukcesu, zero dla awarii. Jest to ramka C i nie posiada mechanizmów do przechwytywania ani propagowania wyjątków w Pascalu.

Właśnie dlatego mechanizm pośredniczący owija ustawianie pozycji i odczyt w blok try/except, który połyka wyjątek i zwraca zero. Gdyby wyjątek z Delphi mógł wydostać się z wywołania zwrotnego, odwinąłby stos ramek cdecl w PDFium, które nigdy nie były budowane z myślą o odwijaniu przez mechanizm wyjątków Pascala. Rezultatem byłoby w najlepszym przypadku zachowanie nieprzewidywalne, a w najgorszym twardy reset programu (crash), głęboko w parserze PDF bez użytecznego stosu. Zwrócenie zera utrzymuje awarię w granicach kontraktu. PDFium widzi nieudany odczyt bloku, przerywa operację w czysty sposób, a funkcja FPDF_LoadCustomDocument zgłasza, że dokument nie mógł zostać załadowany, co komponent ujawnia jako błąd EPdfError po stronie Pascala, gdzie jest jego miejsce.

Otwieranie dokumentu w ten sposób

Metodą komponentu obsługującą ścieżkę strumieniową jest LoadCustomDocument, zadeklarowana jako oddzielna metoda, a nie kolejne przeciążenie LoadDocument, dzięki czemu przekazanie obiektu TMemoryStream nigdy przypadkowo nie wyląduje na ścieżce buforowanej. Buduje ona adapter, wywołuje FPDF_LoadCustomDocument i utrzymuje adapter przy życiu przez cały czas pracy 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 obiektów typu TMemoryStream, strumieni blobów z bazy danych lub niestandardowych obiektów pochodnych TStream. Ładowanie na żądanie sprawdza się, gdy plik jest duży i odczytywana będzie tylko jego część: w przeglądarce archiwów, generatorze miniatur próbkowującym kilka stron czy indeksatorze wyszukiwania pobierającym jedną stronę na raz. Gdy plik jest mały lub i tak zamierzasz odczytać go w całości, ładowanie buforowane jest prostsze, a mechanizm strumieniowy nic nie daje. Czynnikiem decydującym jest stosunek liczby bajtów, które faktycznie zostaną dotknięte, do liczby bajtów zawartych w pliku.

Gdy strony są już strumieniowane na żądanie, kolejną kwestią jest zachowanie responsywności renderowanych stron podczas powiększania i przewijania, co zostało omówione w naszej notatce o buforowaniu renderowania i wydajności powiększania. Gdy strumieniowany dokument to ten, który przeglądarka powinna wyświetlić, ale nie pozwolić użytkownikowi na eksport lub modyfikację, techniki z przewodnika po bezpiecznym podglądzie PDF naturalnie łączą się z tą ścieżką ładowania. Oba te rozwiązania bazują na opisanym tutaj ładowaniu strumieniowym, które jest dostarczane jako część komponentu PDFium Component dla Delphi i C++Builder, obok interfejsów API do renderowania, ekstrakcji tekstu i adnotacji omówionych w innych miejscach tego bloga.