Artykuł techniczny

Przeglądarka PDFium w Delphi: cache renderowania i zoom

Przytrzymaj przycisk powiększenia w naiwnej przeglądarce PDF i popatrz na wykres CPU. Jedno wciśnięcie kontrolki zoomu z autopowtarzaniem odpala tuzin albo więcej kroków powiększenia na sekundę, a jeśli każdy krok rusza pełnej jakości ponowne renderowanie widocznej strony, rendery piętrzą się szybciej, niż się kończą. Strona rasteryzuje się bez zarzutu w izolacji, może 180 ms dla skanu A4, ale teraz uruchamiasz tuzin renderów po 180 ms nad pracą, którą użytkownik już minął. Przeglądarka blokuje się, rdzeń przypina się do 100%, a zanim ekran nadgoni, użytkownik zatrzymał się na poziomie powiększenia sprzed czterech renderów. Lekarstwem nie jest szybszy rasteryzator. Jest nim cache, który zwraca gotowe strony natychmiast, i pętla renderowania gotowa porzucić pracę w chwili, gdy ta się zdezaktualizuje

PDFium Component podaje ci części do obu i nie wtrąca się do polityki. Dostajesz bitmapy należące do wywołującego, renderer progresywny przyjmujący token anulowania, tryby dopasowania przeliczające zoom przy zmianie rozmiaru i wywołanie kafelkowe dla stron zbyt dużych, by rasteryzować je w całości. Czego celowo nie dostarcza, to samego cache, bo właściwa polityka wymiatania zależy od twojego widoku, od pułapu pamięci twojej platformy i od tego, jak przewijają twoi użytkownicy. Ta decyzja należy do ciebie, a konsekwencjami jej zepsucia są właśnie zamrożenie i wyciek

Dokąd idą milisekundy i megabajty

Nałóż liczby na koszt, zanim cokolwiek zaprojektujesz. Strona A4 przy 96 DPI to mniej więcej 794 na 1123 piksele, około 3,5 MB jako bitmapa 32-bitowa. Powiększ do 200%, a to się czterokrotnie zwiększa. Przy 400% na wyświetlaczu o dużej gęstości przydzielasz i zapełniasz pojedynczą bitmapę strony o rozmiarze 50 do 60 MB, a przeglądarka z przewijaniem ciągłym trzyma naraz kilka stron. Koszt rasteryzacji podąża za pikselami wyjścia, więc każde podwojenie powiększenia mniej więcej czterokrotnie zwiększa i czas renderowania, i pamięć naraz

Z tej arytmetyki wypadają wprost dwie konsekwencje. Cache, którego klucz ignoruje poziom powiększenia, jest bezwartościowy, bo dokładnie ten gest, który ma przyspieszać, czyli powiększanie, produkuje za każdym razem nową bitmapę. A cache bez ograniczeń wyczerpie przestrzeń adresową procesu 32-bitowego dokładnie na tych dokumentach, na których ludzie powiększają najmocniej: gęstych skanach aktów własności, rysunkach inżynierskich, mapach wielkoformatowych. Cache musi mieć poprawnie zbudowany klucz i twardy limit, a żadne z tych dwóch nie jest opcjonalne

Co należy do klucza cache

Buforowana bitmapa nadaje się do ponownego użycia tylko wtedy, gdy każde wejście, które ukształtowało jej piksele, wciąż się zgadza. Oznacza to numer strony, efektywne powiększenie (albo równoważnie wymiary wyjścia w pikselach), obrót, DPI monitora i opcje renderowania obowiązujące w chwili jej powstania. Strona wyrenderowana z reAnnotations to inny obraz niż ta sama strona bez nich, a przebieg w skali szarości przez reGrayscale to jeszcze co innego. Wyrzuć którekolwiek z nich z klucza, a błędy są przewidywalne: nakładka adnotacji, która zostaje po tym, jak recenzent skasował komentarz, albo strona, która rozmywa się w chwili, gdy użytkownik przeciąga okno z panelu laptopa na zewnętrzny monitor 4K i DPI zmienia się pod nieaktualną bitmapą

Wyszukiwanie w cache renderowania PDFium w przeglądarce Delphi, gdzie klucz cache łączy stronę, powiększenie, obrót, DPI monitora i opcje renderowania, trafienie zwraca bitmapę w mikrosekundach, a wymiatanie zwalnia każdą porzuconą bitmapę
Klucz cache obejmuje każde wejście kształtujące piksele, a wymiatanie zwalnia bitmapy, które porzuca
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // od teraz cache jest właścicielem bitmapy
end;

Przy trafieniu zwraca to w mikrosekundach, i o to właśnie chodzi. Trudniejszym pytaniem jest to, co dzieje się z bitmapami wypadającymi z cache, a to okazuje się pytaniem o to, kto jest ich właścicielem

Kto zwalnia bitmapę

Funkcyjna postać RenderPage zwraca TBitmap, którego właścicielem jest wywołujący. W jednorazowym eksporcie ta własność jest oczywista i łatwa do uszanowania. Wewnątrz cache staje się najczęstszym pojedynczym wyciekiem w przeglądarkach PDF pisanych w Delphi, bo słownik trzyma teraz jedyną referencję do każdej bitmapy, a zwykły TDictionary zwalnia za ciebie klucze i wartości tylko wtedy, gdy są typami zarządzanymi. TBitmap nim nie jest. Wymieć wpis bez wywołania Free, a piksele zostają przydzielone bez niczego, co by na nie wskazywało

Powodem, dla którego to się prześlizguje, jest czas. Dziesięciominutowy test dymny nigdy nie powiększa tylu odrębnych stron, by to zauważyć; wyciek pokazuje się dopiero po tym, jak ktoś przez parę godzin przewijał i powiększał długi dokument, a wtedy proces trzyma setki osieroconych bitmap stron i maszyna zaczyna stronicować. Dlatego wymiatanie należy do pierwszej wersji cache, a nie do późniejszej. Ogranicz cache szacowaną liczbą bajtów, liczoną jako szerokość razy wysokość razy cztery, wymiataj najdawniej używane strony leżące poza widokiem i oknem pobierania z wyprzedzeniem i zwalniaj każdą bitmapę w chwili jej usuwania. Dla rysowania naprawdę jednorazowego przeciążenia renderujące do dostarczonego przez wywołującego TBitmap albo prosto na HDC pozwalają całkowicie pominąć taniec z własnością. Podgląd wydruku to oczywisty przypadek, bo każdy arkusz renderujesz raz, a buforowanie go nic nie daje

Renderowanie progresywne i uczciwe anulowanie

Zwykłe przeciążenia RenderPage blokują aż do ukończenia strony, czyli zachowują się dokładnie tak, jak nie chcesz, gdy użytkownik wciąż rusza kontrolką zoomu. Do tego sięgasz po RenderPageProgressive. Przyjmuje ono IPdfCancellationToken i zwraca jedno z prsDone, prsCancelled albo prsFailed. Szczegółem zachowania, na którym ludzie się łapią, jest to, że anulowanie nie jest natychmiastowe. Token jest odpytywany na granicach porcji wewnątrz renderowania, więc token zasygnalizowany w środku porcji zadziała dopiero po jej ukończeniu. Na złożonej stronie opóźnienie między prośbą a zatrzymaniem sięga dziesiątek milisekund. Projektuj wokół tej luki, zamiast życzyć sobie, by zniknęła: anuluj poprzedni token w chwili nadejścia nowej wartości powiększenia, ale nie zakładaj, że stary render zatrzymuje się w chwili, gdy o to poprosisz

Oś czasu renderowania progresywnego PDFium w Delphi, gdzie każde nowe żądanie powiększenia anuluje poprzedni token, anulowanie ląduje na granicy porcji, wyparte rendery zwracają prsCancelled, a ostatnia próba zwraca prsDone
Każde nowe żądanie powiększenia anuluje poprzedni token renderowania, a anulowanie ląduje na granicy porcji
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // porzuć poprzedni render w locie
  FTokenSource := TPdfCancellationTokenSource.New;  // moduł FPdfAsync

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // wyparty przez nowsze żądanie: porzuć po cichu
    prsFailed:    ShowRenderFailure;
  end;
end;

Podczas interakcji prsCancelled jest wynikiem normalnym, a nie wyjątkowym. Większość renderów rozpoczętych przez gest powiększania zostanie wyparta, zanim się skończy, więc traktuj anulowanie jako rutynę i porzucaj wynik po cichu. Kolejka renderowania, która loguje każde anulowanie jako ostrzeżenie, zakopie tę jedną awarię, która naprawdę ma znaczenie, pod tysiącami linii szumu. Żeby ekran nie wyglądał na martwy, gdy biegnie prawdziwy render, sparuj ścieżkę progresywną z tanim zamiennikiem: przeskaluj poprzednią buforowaną bitmapę do nowego powiększenia i pokaż ją natychmiast. Przez sto czy dwieście milisekund wygląda miękko, ale czyta się jako natychmiastowa, i kupuje renderowi pełnej jakości czas, którego potrzebuje, by albo się skończyć, albo zostać anulowanym przez następny gest

Tryb dopasowania, który zoom po cichu wyłącza

Właściwość FitMode przeglądarki, ustawiona na pfmFitPage albo pfmFitWidth, przelicza powiększenie przy każdej zmianie rozmiaru, żeby strona wciąż się mieściła, gdy okno się zmienia. Haczyk polega na tym, że bezpośrednie przypisanie Zoom resetuje FitMode z powrotem do pfmNone. Jako ustawienie domyślne jest to poprawne: użytkownik, który celowo wpisał 150%, nie chce, by następna zmiana rozmiaru okna to wyrzuciła. Ale zaskakuje to każdego, kto podpina przycisk powiększania jako Zoom := Zoom * 1.25, a potem nie może dojść, czemu dopasowanie do szerokości przestało reagować po pierwszym kliknięciu. Jeśli twój pasek narzędzi oferuje i jawne powiększenie, i tryby dopasowania, musisz sam zapamiętać ostatni wybór dopasowania użytkownika i przypisać go ponownie, gdy naciśnie przycisk dopasowania. Komponent nie przywróci trybu, który przed chwilą wyczyściło przypisanie powiększenia, i nie powinien

Budżet pamięci, którego da się bronić

Budżet, który da się zapisać, to budżet, którego da się bronić w przeglądzie kodu, więc zacznij od konkretnego scenariusza. Powiedzmy, że przewijanie ciągłe trzyma widoczną stronę plus jedną pobraną z wyprzedzeniem powyżej i poniżej, obok paska miniatur. Przy 100% na wyświetlaczu 96 DPI te trzy pełnowymiarowe bitmapy to około 3,5 MB każda, czyli nic. Przy 300% na wyświetlaczu 4K te same trzy bitmapy to mniej więcej 30 MB każda, i to zanim cache zatrzyma choć jedną stronę historyczną. Wzrost siedzi w geście, a nie w dokumencie

Arytmetyka pamięci bitmap PDFium dla przeglądarki w Delphi, gdzie każde podwojenie powiększenia czterokrotnie zwiększa pamięć strony, przewijanie ciągłe trzyma trzy strony żywe, ograniczony budżet LRU broni cache, a RenderTile obsługuje strony ponadwymiarowe
Każde podwojenie powiększenia czterokrotnie zwiększa pamięć bitmap, więc cache potrzebuje twardego limitu i kafelków dla stron ponadwymiarowych

Rozsądnym ustawieniem domyślnym dla 32-bitowego procesu Delphi jest budżet bitmap 256 MB przy wymiataniu LRU. Na 64 bitach możesz skalować go z pamięcią fizyczną, ale mimo wszystko trzymaj twardy sufit, bo awaria, przed którą się bronisz, to nie wywalenie się twojego procesu. To cała maszyna miotająca plikiem stronicowania, podczas gdy twoja przeglądarka technicznie działa dalej, a użytkownik zastanawia się, czemu wszystko inne zwolniło. Twardy limit zawodzi przewidywalnie; cache bez ograniczeń zawodzi, zabierając ze sobą cały pulpit. Miniatury zasługują na własne traktowanie: wyrenderuj każdą raz w jej małym rozmiarze docelowym i trzymaj w osobnej puli, której logika LRU nigdy nie dotyka. Odtwarzanie miniatury o szerokości 120 pikseli przez pomniejszanie pełnostronicowej bitmapy o rozmiarze 60 MB to najbardziej rozrzutny możliwy sposób wyprodukowania znaczka pocztowego

Niektóre pojedyncze strony pokonują każdy budżet. Rysunek inżynierski w formacie E albo duża mapa wyrenderowana w całości przy 400% to przydział rzędu wielu setek megabajtów i żadna polityka wymiatania nie czyni tego akceptowalnym. Odpowiedzią jest tam zaprzestanie renderowania całych stron. RenderTile rasteryzuje wyłącznie obszar w przesunięciu pikselowym (Left, Top) wewnątrz strony teoretycznie przeskalowanej do PageWidth na PageHeight, więc renderujesz tylko widoczny prostokąt plus margines jednego kafelka wokół niego dla płynnego przesuwania, a przesunięcia kafelków wpinasz w klucz cache obok powiększenia. Trzymaj wymiary kafelka stałe w całym dokumencie. Stałe kafelki oznaczają, że zmiana DPI unieważnia całą siatkę czysto, podczas gdy kafelki zmienne skazują cię na ściganie widocznych szwów między obszarami renderowanymi w nieco różnych skalach

Dwie sąsiednie funkcje po cichu dokładają się do tego wszystkiego. Przebiegi filtrów kolorów, takie jak skala szarości albo inwersja, działają po renderowaniu i produkują za każdym razem drugą pełnowymiarową bitmapę, podwajając ślad pamięciowy na stronę w każdym widoku, który ich używa; ten koszt jest tematem artykułu o filtrach kolorów dla słabowidzących w przeglądarkach PDF w Delphi. A przeglądarka, która podświetla słowa podczas czytania na głos, unieważnia wyrenderowany widok przy każdym wypowiedzianym słowie, więc interakcja między przemalowaniami podświetlenia a tempem mowy ma większe znaczenie, niż się na początku wydaje, co omawiamy w artykule o podświetlaniu TTS słowo po słowie

Przeciążenia renderujące, kody statusu renderowania progresywnego i sam komponent przeglądarki są udokumentowane na stronie produktu PDFium Component