Technisch artikel

PDF-viewer met doorlopend scrollen in Delphi met PDFium Component

Een enkele A4-pagina die op een comfortabele leeszoom wordt gerenderd, beslaat een paar megabytes aan 32-bits bitmap. Vermenigvuldig dat met een contract van 400 pagina's en de berekening houdt op abstract te zijn: als u elke pagina vooraf rendert, vraagt u Windows om ruim een gigabyte aan bitmaps die de gebruiker slechts één scherm per keer zal bekijken. De applicatie raakt in een 32-bits build zonder adresruimte of is de eerste paar seconden bevroren terwijl de GPU en de paginaparser pagina's verwerken waar de gebruiker nog niet eens naartoe is gescrolld. Een lezer met doorlopend scrollen (continuous scrolling) moet aanvoelen als één lang lint van pagina's, maar kan deze in werkelijkheid niet allemaal tegelijk in het geheugen houden

Die spanning is het hele probleem. PDFium Component lost dit op binnen TPdfView, dus het meeste werk bestaat uit het kiezen van de juiste weergavemodus en begrijpen wat de component namens u doet. De onderdelen die het niet voor u doet, zoals het dimensioneren van pagina's voor een leesstroom en het responsief houden van snel scrollen, zijn de plekken waar een beetje code het verschil maakt. Als u nog bezig bent met het bouwen van de omringende interface (werkbalk, miniaturen, zoekvak), behandelt de handleiding voor een functierijke viewer dat gebied; hier is het onderwerp het scrollen zelf

De lay-out is een weergavemodus, geen paneel met bitmaps

Het instinct bij VCL-formulierwerk is om te grijpen naar een scrollbox en daar image-controls in te stapelen, één per pagina. Weersta die neiging. Dat ontwerp dwingt u om paginapositionering, scrollberekeningen en het geheugenvraagstuk allemaal tegelijk in eigen hand te nemen, en u zult elk daarvan slecht heruitvinden. TPdfView modelleert het document al als een doorlopende stroom van pagina's en stelt de lay-out beschikbaar via de eigenschap DisplayMode

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // één pagina breed, scrolt verticaal

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Kon het document niet openen');

Dat is de volledige installatie voor doorlopend scrollen. dmSingleContinuous lay-outt de pagina's in een enkele verticale kolom waarbij de tussenruimtes intern worden afgehandeld, en de weergave scrolt door die kolom als één geheel oppervlak. Er is geen controle per pagina om aan te sluiten en geen scroll-handler om te schrijven voor normale navigatie. Let op de controle op Pdf.Active na de toewijzing: het openen van een document nooit uitzonderingen op, dus een beschadigd of met een wachtwoord beveiligd bestand laat Active op False staan zonder een op te vangen uitzondering (exception), en een viewer die deze controle overslaat rendert een leeg paneel en geeft zichzelf de schuld

Dezelfde eigenschap draagt de spread-modi. dmTwoPageContinuous plaatst pagina's naast elkaar, twee per rij, voor het boekachtige lezen dat sommige documenten vereisen; dmTwoPageContinuousWithCover doet hetzelfde, maar laat pagina één op zichzelf staan als een omslag, zodat de resterende spreads op de natuulijke even-oneven grens vallen. Alle drie scrollen ze doorlopend. Het schakelen daartussen is een enkele toewijzing, wat een combo-box voor de weergavemodus later eenvoudig toe te voegen maakt

Alleen de zichtbare pagina's worden gerasterd

De reden dat dit schaalt naar een bestand van 400 pagina's, is dat de kolom virtueel is. TPdfView kent de hoogte van elke pagina uit de paginaboom van het document, zodat het de totale scrollgrootte en de positie van elke pagina kan berekenen zonder iets te rasteren. Rasteren, de dure stap die de inhoudsstroom van een pagina omzet in pixels, gebeurt alleen voor de pagina's die momenteel de viewport kruisen, plus een kleine marge zodat een pagina klaar is tegen de tijd dat deze in beeld scrolt. Terwijl u naar beneden scrolt, worden pagina's die de viewport binnenkomen gerenderd en worden de bitmaps van pagina's die deze verlaten vrijgegeven. Het geheugen blijft proportioneel aan wat op het scherm past, niet aan de lengte van het document

Dit is de moeite waard om te internaliseren, omdat het de manier verandert waarop u over de kosten nadenkt. Het openen van een document van 400 pagina's is goedkoop: het ontleedt de structuur, niet de inhoud. De kosten zijn per pagina en worden lui (lazy) betaald, op het moment dat een pagina dichtbij wordt gescrolld. Een viewer die direct aanvoelt bij het openen en soepel scrolt, doet over het algemeen niet minder werk; hij spreidt het werk over het werkelijke leespad van de gebruiker en gooit weg wat achterblijft. Het praktische gevolg is dat u pagina's bijna nooit van tevoren geforceerd wilt renderen. Laat de weergave beslissen wat zichtbaar is

Dimensioneer pagina's naar de breedte, laat de zoom daarna met rust

Een leeskolom wil pagina's die aan de breedte van het paneel zijn aangepast, en niet vastgezet op een absolute zoom. FitMode doet dit en blijft dit doen wanneer het venster van grootte verandert

PdfView.FitMode := pfmFitWidth;   // elke pagina vult de breedte van de kolom; de hoogte volgt

Met pfmFitWidth herberekent de component de zoom telkens wanneer de weergave van grootte verandert, zodat de kolom altijd de beschikbare breedte vult en de paginahoogtes, en dus de scrollgrootte, daaruit volgen. Er is één valkuil waar mensen intrappen: het rechtstreeks toewijzen van Zoom reset FitMode terug naar pfmNone. Dat is bewust, omdat een handmatige zoom en een automatische aanpassing tegenstrijdige intenties zijn, maar het betekent dat een verdwaalde PdfView.Zoom := 1.0 ergens in uw code geruisloos de pas-naar-breedte uitschakelt en de volgende resizing stopt met reflowen. Als u zowel een zoomregelaar als een pas-knop aanbiedt, behandel ze dan als een modusschakelaar: het instellen van de ene wist de andere, en u beslist welke wint

Voor absolute zoomregelaars die natuurlijk lezen, stelt de weergave de fit-zooms beschikbaar als waarden die u kunt toepassen of weergeven: PageWidthZoom[PageNumber] retourneert de zoom die die pagina aan de breedte zou aanpassen, en de bijbehorende PageZoom past de hele pagina aan. Het lezen daarvan is hoe u een menu "Pas breedte aan" / "Pas pagina aan" vult zonder magische percentages hard te coderen die fout gaan op liggende of extra grote pagina's

Houd snel scrollen responsief met progressief renderen

Het standaard renderpad tekent een pagina volledig uit voordat het terugkeert. Voor een enkele pagina is dat prima. Tijdens snel scrollen (flick-scroll) door een compact document is dat niet het geval: elke pagina die voorbij flitst start een volledige rastering, en als de gebruiker sneller scrolt dan pagina's kunnen renderen, stapelen die renders zich op en hapert het paneel omdat er werk wordt gedaan voor pagina's die al buiten beeld zijn tegen de tijd dat het klaar is. De oplossing is om een render annuleerbaar te maken en deze af te breken zodra de gebruiker verdergaat

RenderPageProgressive rendert in blokken (chunks) en controleert bij elke blokgrens een annuleringstoken, zodat een lopende render van een pagina die net is weggescrolld kan worden afgebroken in plaats van tot het einde toe te worden uitgevoerd

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Annuleer wat er ook aan het renderen was; het oude token is nu gesignaleerd.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is voltooid, teken deze
    prsCancelled: Exit;                // vervangen, gooi dit resultaat weg
    prsFailed:    ShowMessage('Renderen mislukt voor pagina ' + IntToStr(PageNo));
  end;
end;

De vorm die er toe doet, is de retourwaarde. prsDone betekent dat de bitmap volledig is getekend en het waard is om op het scherm te worden gezet; prsCancelled betekent dat een nieuwere scrollpositie deze pagina heeft vervangen, dus u gooit het gedeeltelijke resultaat weg in plaats van het te tonen; prsFailed is een echte fout op die pagina. Annulering wordt gepeild bij blokgrenzen in plaats van preventief, dus verwacht enkele tientallen milliseconden latentie tussen het aanroepen van Cancel en het daadwerkelijk stoppen van de render. Dat is nog steeds veel goedkoper dan een verouderde render van een hele pagina de wachtrij te laten blokkeren. Het doorgeven van nil als token rendert direct door tot aan de voltooiing, wat de juiste keuze is voor een eenmalige render zoals een afdrukvoorbeeld waar niets te annuleren valt

Wanneer u in plaats daarvan de functievorm van RenderPage aanroept, degene die een nieuwe TBitmap retourneert, onthoud dan dat de aanroeper deze bezit en moet vrijgeven via Free. In een scroll-lus die een bitmap per pagina alloceert, is het vergeten hiervan een lek dat groeit met elke pagina die de gebruiker passeert, wat precies de fout van onbegrensd geheugen is die het doorlopende ontwerp moest vermijden. Render waar mogelijk in een hergebruikte bitmap

Wat u overhoudt

De lezer met doorlopend scrollen is hoofdzakelijk aan de component om te leveren. U kiest dmSingleContinuous voor de lay-out, stelt pfmFitWidth in zodat de kolom met het venster mee reflowt, en controleert Pdf.Active zodat een fout bestand luidkeels faalt. Het enige onderdeel dat de moeite waard is om zelf te schrijven, is het annuleerbare renderen, omdat een lezer wordt beoordeeld op hoe deze zich gedraagt wanneer iemand de schuifbalk naar de onderkant van een lang document sleept en het paneel al dan niet bijblijft. Alles daarbuiten, zoals tekstselectie over pagina's heen, zoekmarkering en een bladwijzerboom, is interface-werk dat bovenop dit scroll-oppervlak ligt in plaats van erin

De TPdfView-, DisplayMode- en RenderPageProgressive-API's die hier worden getoond, maken deel uit van het PDFium Component voor Delphi en Lazarus