Artykuł techniczny

Kolejka renderowania PDF w tle w Delphi z HotPDF

Klasa THPDFBackgroundRenderer w HotPDF to potomek TThread, który renderuje wczytane strony PDF do bitmap na wątku roboczym, dzięki czemu przeglądarka w Delphi może dalej przewijać i przemalowywać się, gdy strona wciąż jest rasteryzowana w tle. THPDFBackgroundRenderer.RequestPage kolejkuje indeks strony dla tego wątku roboczego, CancelAll odrzuca wszystko, co jeszcze czeka, a GetCachedBitmap zwraca gotową bitmapę, której właścicielem staje się wywołujący i którą musi zwolnić. Przewiń dwustustronicową skanowaną umowę w rozdzielczości druku wyłącznie na wątku UI, a każde przewrócenie strony zatrzyma okno, dopóki GDI nie skończy jej rysować — dokładnie to zacinanie się ma usunąć THPDFBackgroundRenderer

Dlaczego w ogóle renderować strony PDF na wątku w tle?

Wątek w tle zasługuje na swoją złożoność, ponieważ renderer stron HotPDF to prawdziwy interpreter strumienia treści, a nie tania kopia bitmapy, która wraca, zanim ktokolwiek to zauważy: przechodzi przez operatory PDF, utrzymuje stos stanu grafiki i rasteryzuje ścieżki, obrazy i glify przez GDI, ten sam silnik opisany w renderowaniu wczytanych stron PDF do TBitmap. Uruchomienie tej pracy synchronicznie wewnątrz procedury obsługi przewijania albo malowania zatrzymuje pompowanie pętli komunikatów do momentu powrotu z wywołania, a to właśnie jest zamrożone okno. Wstawienie Application.ProcessMessages wewnątrz wywołania renderującego tego nie naprawia: pozwala kolejce komunikatów się opróżnić, ale samo renderowanie nadal jest właścicielem wywołującego wątku, więc okno przemalowuje nieaktualną zawartość szybciej, podczas gdy prawdziwa praca nigdzie się nie przesunęła. Jedynym sposobem na utrzymanie responsywności przeglądarki podczas rzeczywiście wolnego renderowania jest uruchomienie tego renderowania gdzie indziej, dlatego THPDFBackgroundRenderer istnieje jako podklasa TThread, a nie jako callback czy timer

Konfigurowanie kolejki żądań dla przewijanej przeglądarki

THPDFBackgroundRenderer.Create przyjmuje wczytaną instancję THotPDF oraz DPI, które pozostaje stałe przez cały żywot tego renderera, więc każda strona zakolejkowana przez jedną instancję renderuje się w jednej rozdzielczości; przeglądarka obsługująca powiększenie potrzebuje nowego renderera, a nie nowej właściwości DPI, za każdym razem gdy poziom powiększenia się zmienia. RequestPage dopisuje indeks strony do wewnętrznej kolejki i natychmiast wraca: sam nie wykonuje żadnego renderowania i nigdy nie dotyka wątku UI. Execute, odziedziczony punkt wejścia TThread, który HotPDF uruchamia po wywołaniu Start, pobiera po jednym indeksie z przodu tej kolejki, renderuje go przez bufor stron dokumentu i przechowuje kopię indeksowaną według strony, żeby GetCachedBitmap mogła ją później zwrócić

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap zwraca nil, dopóki kopia danej strony nie jest gotowa, więc wzorzec odpytywania timerem taki jak powyższy w zupełności wystarcza; nie ma osobnego zdarzenia gotowości do podłączenia — HotPDF rozwiązuje to zwykłym sprawdzeniem nil zamiast rozbudowanego API powiadomień. Kolejna sekcja opisuje, co dokładnie robią CancelAll i to wywołanie Free, ponieważ oba mają znaczenie, gdy strony zaczynają się renderować nie po kolei albo przewijanie dzieje się szybciej, niż kolejka nadąża się opróżniać

Skrót jednowywołaniowy dla pojedynczej strony

THotPDF.RenderLoadedPageToBitmapAsync istnieje dla typowego przypadku odpalenia dokładnie jednej strony bez bezpośredniego dotykania THPDFBackgroundRenderer: konstruuje renderer wewnętrznie, wywołuje RequestPage raz, uruchamia wątek i zwraca referencję TThread wywołującemu, który staje się jej właścicielem i odpowiada za jej zwolnienie. Pobieranie wyniku przechodzi przez THotPDF.GetLoadedCachedRenderedBitmap, a nie własne GetCachedBitmap renderera, ponieważ GetLoadedCachedRenderedBitmap odczytuje współdzielony bufor dokumentu indeksowany według strony i DPI, ten sam bufor, który już wypełniają RenderLoadedPageToBitmapCached i wbudowany prefetcher — strona, którą jakaś inna część przeglądarki już wyrenderowała w tym DPI, może wrócić natychmiast, zanim system operacyjny w ogóle zdąży zaplanować dopiero co uruchomiony wątek w tle

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

Czy można anulować stronę, która już jest zakolejkowana?

CancelAll usuwa tylko zadania wciąż siedzące w kolejce; strona, którą HotPDF już pobrał z przodu i przekazał do swojego wywołania renderującego, kontynuuje aż do zakończenia, ponieważ THPDFBackgroundRenderer nie ma mechanizmu przerywania pracy już w toku. W praktyce to rozsądny kompromis — renderowanie pojedynczej strony rzadko trwa na tyle długo, żeby wywłaszczenie było warte dodatkowej złożoności — ale szybkie przewijanie, które odpala CancelAll przy każdym zdarzeniu przewijania, wciąż płaci koszt tej jednej strony, która była w trakcie renderowania w chwili każdego anulowania. Oficjalna dokumentacja jest w tej kwestii wprost: już uruchomione renderowanie może zakończyć się, zanim wątek się zakończy

Execute ma drugie, łatwe do przeoczenia zachowanie: pętla kończy się, gdy tylko znajdzie pustą kolejkę, nie bezczynnie czeka na nadejście kolejnej pracy. Instancja THPDFBackgroundRenderer jest więc jednorazowym wsadowym pracownikiem, a nie trwałą usługą w tle — zakolejkuj garść stron, wywołaj Start, a gdy ostatnia zakolejkowana strona się wyrenderuje, leżący u podstaw wątek systemu operacyjnego kończy się samodzielnie. Ponowne wywołanie RequestPage na tej samej instancji po tym, jak Execute już opróżnił kolejkę, jej nie restartuje, i dokładnie dlatego RequestPageWindow powyżej zastępuje instancję renderera przy każdym wywołaniu, zamiast próbować dalej karmić jeden długożyjący obiekt

Czy dotykanie TBitmap z wątku w tle jest bezpieczne w Delphi?

Dotykanie TBitmap z wątku w tle jest bezpieczne w projekcie HotPDF, dopóki tylko jeden wątek naraz operuje na danej instancji bitmapy, a THPDFBackgroundRenderer wymusza tę granicę, zamiast zostawiać ją wywołującemu. Execute renderuje każdą stronę wewnątrz własnej blokady renderowania dokumentu, tej samej sekcji krytycznej, którą dzieli już każde wywołanie RenderLoadedPageToBitmapCached i wbudowany prefetcher PrefetchLoadedPages, więc faktyczne rysowanie GDI dla danej strony odbywa się dokładnie na jednym wątku naraz i nigdy nie nakłada się na inne renderowanie tego dokumentu. Wynikowa bitmapa jest obiektem należącym do wątku roboczego, którego THPDFBackgroundRenderer nigdy nie publikuje bezpośrednio wywołującemu

GetCachedBitmap zamiast tego przydziela zupełnie nową TBitmap i wywołuje na niej Assign pod własną, oddzielną blokadą renderera, więc kopiowanie zawsze zachodzi, gdy Execute jest zablokowany przed podmienieniem tego slotu bufora pod spodem — wywołujący wątek dostaje dane pikseli, nigdy oryginalny uchwyt. To rozdzielenie jest też powodem, żeby powstrzymać się od budowania własnego wątku renderującego, który wywołuje funkcje renderujące HotPDF bezpośrednio, z pominięciem THPDFBackgroundRenderer albo PrefetchLoadedPages: dwa renderowania ścigające się o te same współdzielone bufory i graf obiektów jednego wczytanego dokumentu to dokładnie scenariusz, przed którym ma chronić wewnętrzne blokowanie HotPDF, a klasa renderera w tle daje to blokowanie za darmo zamiast reimplementowania go

Czym różni się to od wbudowanego prefetchu stron w HotPDF?

PrefetchLoadedPages i THPDFBackgroundRenderer rozwiązują pokrewne, ale różne problemy: PrefetchLoadedPages, mając zakres stron, renderuje całe to sąsiedztwo do współdzielonego bufora dokumentu automatycznie na własnym wątku roboczym, bez obiektu kolejki, który wywołujący musiałby utworzyć czy zarządzać nim. THPDFBackgroundRenderer wymienia tę automatykę na kontrolę — wywołujący decyduje dokładnie, które indeksy stron mają znaczenie i w jakiej kolejności, i może anulować te wciąż zakolejkowane bez dotykania zakresu, który wbudowany prefetcher rozgrzewa gdzie indziej. Oba przechodzą przez tę samą blokadę renderowania, więc przeglądarka może uruchomić PrefetchLoadedPages dla zwykłego przypadku kilku kolejnych stron i sięgnąć po THPDFBackgroundRenderer tylko wtedy, gdy pojawi się coś spoza tego wzorca, na przykład pasek miniatur skaczący prosto do strony, którą użytkownik właśnie kliknął

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Warto przenieść do kodu produkcyjnego dwa szczegóły cyklu życia. Współdzielony bufor dokumentu za RenderLoadedPageToBitmapCached jest ograniczony przez RenderCacheCapacity, domyślnie osiem stron, i usuwa najdawniej używany wpis po zapełnieniu, ale własna lista wyników instancji THPDFBackgroundRenderer nie ma takiego limitu — przechowuje jedną bitmapę na każdy odrębny indeks strony kiedykolwiek zażądany przez tę instancję, dopóki sama instancja nie zostanie zwolniona, więc renderer utrzymywany przy życiu przez całą sesję przewijania przy wysokim DPI chętnie zgromadzi jedną pełnorozdzielczą bitmapę na każdą przewiniętą stronę. HotPDF też nie anuluje automatycznie renderera utworzonego przez wywołującego tak, jak anuluje własny prefetcher przed wczytaniem dokumentu albo zniszczeniem samego siebie, ponieważ instancja THPDFBackgroundRenderer nigdy nie jest zarejestrowana na obiekcie THotPDF, na który wskazuje — więc kod wywołujący musi anulować i zwolnić każdy renderer zbudowany dla dokumentu przed ponownym wczytaniem albo zwolnieniem tego dokumentu, ta sama dyscyplina kolejności, jaką HotPDF stosuje wewnętrznie do PrefetchLoadedPages

THPDFBackgroundRenderer to jeden element fasady wczytanego dokumentu stojącej za architekturą MVC przeglądarki HotPDF, i naturalnie łączy się z przepływami pracy na poziomie pliku z Direct File API dla dużych plików PDF, gdy przewijany dokument sam w sobie jest zbyt duży, żeby wczytać go od niechcenia. Renderowanie w tle, kolejki żądań i bufor renderowania opisane tutaj są częścią standardowego komponentu HotPDF dla Delphi i C++Buildera