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