Teknisk artikel

PDFium Delphi-fremviser: Gengivelsescache og taktikker til jævn zoom

Hold zoomknappen nede i en naiv PDF-viewer, og hold øje med CPU-grafen. Ét tryk på en auto-gentagende zoomkontrol affyrer et dusin eller flere zoomtrin i sekundet, og hvis hvert trin igangsætter en fuldkvalitets gen-rendering af den synlige side, hober rendringerne sig op hurtigere, end de bliver færdige. Siden rasteriseres fint isoleret, måske 180 ms for en A4-scanning, men man kører nu et dusin 180 ms-renderinger mod arbejde, brugeren allerede er kommet forbi. Viewer'en låser, en kerne sidder fast på 100%, og når skærmen indhenter det, er brugeren stoppet ved et zoomniveau fire renderinger tilbage. Kuren er ikke en hurtigere rasterizer. Det er en cache, der returnerer færdige sider øjeblikkeligt, og en render-løkke, der er villig til at opgive arbejde i det øjeblik, det bliver forældet

PDFium Component giver dig delene til begge dele og blander sig ikke i politikken. Du får bitmaps, som kalderen ejer, en progressiv renderer, der tager et annulleringstoken, tilpasningstilstande, der genberegner zoom ved størrelsesændring, og et tile-kald til sider, der er for store til at rasterisere hele. Det, komponenten bevidst ikke leverer, er selve cachen, fordi den rette udsmidningspolitik afhænger af din viewport, din platforms hukommelsesloft og den måde, dine brugere scroller på. Den beslutning er din at få rigtig, og konsekvenserne af at få den galt er præcis fastlåsningen og lækken

Hvor millisekunderne og megabyttene forsvinder hen

Sæt tal på omkostningen, før du designer noget som helst. En A4-side ved 96 DPI er cirka 794 gange 1123 pixels, omkring 3,5 MB som en 32-bit bitmap. Zoom til 200%, og det firdobles. Ved 400% på en skærm med høj DPI allokerer og udfylder man en enkelt sidebitmap på 50 til 60 MB, og en viewer med kontinuerlig scroll holder flere sider levende ad gangen. Rasteriseringsomkostningen følger outputpixels, så hver fordobling af zoom firdobler nogenlunde både rendertid og hukommelse sammen

To konsekvenser følger direkte af det regnestykke. En cache, hvis nøgle ignorerer zoomniveauet, er værdiløs, fordi selve den gestus, den skal accelerere, zoomning, producerer en ny bitmap hver gang. Og en ubegrænset cache vil løbe tør for adresseplads i en 32-bit proces netop på de dokumenter, hvor folk zoomer hårdest: tætte skøde-scanninger, tekniske tegninger, storformatkort. Cachen skal nøgles korrekt og begrænses fast, og ingen af delene er valgfri

Hvad hører hjemme i cache-nøglen

En cachet bitmap er kun sikker at genbruge, når hvert input, der formede dens pixels, stadig stemmer. Det betyder sidetallet, den effektive zoom (eller ækvivalent outputpixel-dimensionerne), rotationen, skærmens DPI og de render-indstillinger, der var gældende, da den blev produceret. En side rendret med reAnnotations er et andet billede end den samme side uden dem, og et gråtonepass gennem reGrayscale er igen forskelligt. Udelad blot ét af disse fra nøglen, og fejlene er forudsigelige: en annotationsoverlejring, der hænger ved, efter en reviewer sletter kommentaren, eller en side, der bliver sløret i det øjeblik, en bruger trækker vinduet fra et laptop-panel til en ekstern 4K-skærm, og DPI'en ændrer sig under en forældet bitmap

PDFium render cache-opslag i en Delphi-viewer, hvor cachenøglen kombinerer side, zoom, rotation, monitor-DPI og render options, et hit returnerer bitmapen på mikrosekunder, og eviction frigør hver bitmap, den dropper
Cache-nøglen dækker hvert input, der former pixels, og eviction frigør de bitmaps, den dropper
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);   // cachen ejer nu denne bitmap
end;

Ved et hit returnerer dette på mikrosekunder, hvilket er hele pointen. Det sværere spørgsmål er, hvad der sker med de bitmaps, der falder ud af cachen, og det viser sig at være et spørgsmål om, hvem der ejer dem

Hvem frigiver bitmappen

Funktionsformen af RenderPage returnerer en TBitmap, som kalderen ejer. I en engangseksport er det ejerskab åbenlyst og let at overholde. Inde i en cache bliver det den absolut mest almindelige lækage i Delphi-PDF-viewere, fordi dictionary'et nu holder den eneste reference til hver bitmap, og et almindeligt TDictionary frigiver kun nøgler og værdier for dig, hvis de er managed typer. Det er en TBitmap ikke. Udsmid en indgang uden at kalde Free, og pixels forbliver allokeret uden noget, der peger på dem

Grunden til, at dette glider igennem, er timing. En ti-minutters røgtest zoomer aldrig nok forskellige sider til at bemærke det; lækagen viser sig først, efter nogen har scrollet og zoomet i et langt dokument i et par timer, på hvilket tidspunkt processen holder på hundredvis af forældreløse sidebitmaps, og maskinen begynder at page. Det er grunden til, at udsmidning hører hjemme i cachens første version, ikke en senere. Begræns cachen efter estimerede bytes, beregnet som bredde gange højde gange fire, udsmid de mindst-nyligt-brugte sider, der ligger uden for viewport og prefetch-vinduet, og frigiv hver bitmap, når du fjerner den. For tegninger, der reelt er flygtige, lader overloadene, der rendrer ind i en TBitmap leveret af kalderen eller direkte på en HDC, dig springe ejerskabsdansen helt over. Udskriftsforhåndsvisning er det oplagte tilfælde, eftersom man renderer hvert ark én gang, og at cache det giver intet

Progressiv rendering og ærlig annullering

De almindelige RenderPage-overloads blokerer, indtil siden er færdig, hvilket er præcis den adfærd, man ikke ønsker, mens brugeren stadig bevæger zoomkontrollen. Til det griber man til RenderPageProgressive. Den tager et IPdfCancellationToken og returnerer enten prsDone, prsCancelled eller prsFailed. Den adfærdsdetalje, der fanger folk, er, at annullering ikke er øjeblikkelig. Tokenet polles ved chunk-grænser inde i renderingen, så et token, man signalerer midt i en chunk, træder først i kraft, når den chunk er færdig. På en kompleks side løber ventetiden mellem forespørgsel og stop op i titalls millisekunder. Design omkring det hul i stedet for at ønske det væk: annullér det forrige token i det øjeblik, en ny zoomværdi ankommer, men gå ikke ud fra, at den gamle rendering stopper, i det øjeblik man beder om det

PDFium progressiv renderingstidslinje i Delphi, hvor hver ny zoomanmodning annullerer det forrige token, annullering lander ved en chunkgrænse, fortrængte renders returnerer prsCancelled, og det endelige forsøg returnerer prsDone
Hver ny zoom-forespørgsel annullerer det forrige render-token, og annulleringen lander ved en chunk-grænse
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // opgiv den forrige rendering, der er i gang
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync-modulet

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // afløst af en nyere anmodning: drop stille
    prsFailed:    ShowRenderFailure;
  end;
end;

Under interaktion er prsCancelled det normale udfald, ikke det exceptionelle. De fleste af de renderinger, en zoomgestus starter, vil blive afløst, før de bliver færdige, så behandl annullering som rutine, og drop resultatet stille. En renderkø, der logger hver annullering som en advarsel, vil begrave den ene fejl, der rent faktisk betyder noget, under tusindvis af linjer støj. For at forhindre, at skærmen ser død ud, mens den rigtige rendering kører, kan man parre den progressive sti med en billig stedfortræder: skalér den forrige cachede bitmap til den nye zoom, og præsentér den øjeblikkeligt. Det ser blødt ud i et par hundrede millisekunder, men det opleves som øjeblikkeligt, og det køber fuldkvalitetsrenderingen den tid, den har brug for til enten at blive færdig eller blive annulleret af den næste gestus

Tilpasningstilstanden, som zoom stille slår fra

En viewers FitMode-egenskab, sat til pfmFitPage eller pfmFitWidth, genberegner zoom ved hver størrelsesændring, så siden bliver ved med at passe, efterhånden som vinduet ændrer sig. Faldgruben er, at en direkte tildeling af Zoom nulstiller FitMode tilbage til pfmNone. Som standard er det korrekt: en bruger, der bevidst har indtastet 150%, ønsker ikke, at den næste vinduesstørrelsesændring smider det væk. Men det overrasker enhver, der forbinder en zoom-ind-knap som Zoom := Zoom * 1.25 og derefter ikke kan forstå, hvorfor tilpas-til-bredde stoppede med at reagere efter det første klik. Hvis din værktøjslinje tilbyder både eksplicit zoom og tilpasningstilstande, skal man selv huske brugerens sidste tilpasningsvalg og gentildele det, når vedkommende trykker på tilpasningsknappen igen. Komponenten genopretter ikke en tilstand, som en zoom-tildeling lige har ryddet, og det er heller ikke meningen, den skal

Et hukommelsesbudget, man kan forsvare

Et budget, man kan skrive ned, er et budget, man kan argumentere for i en kodegennemgang, så start fra et konkret scenarie. Sig, at kontinuerlig scroll holder den synlige side plus én forudhentet side over og under, ved siden af en miniaturestrimmel. Ved 100% på en 96-DPI-skærm kommer disse tre bitmaps i fuld størrelse til omkring 3,5 MB hver, hvilket er ingenting. Ved 300% på en 4K-skærm er de samme tre bitmaps omkring 30 MB hver, og det er før cachen har beholdt en eneste historisk side. Væksten ligger i gestussen, ikke i dokumentet

PDFium bitmap hukommelsesregnestykke for en Delphi-viewer, hvor hver fordobling af zoom firedobler sidehukommelsen, continuous scroll holder tre sider live, et kappet LRU-budget forsvarer cachen, og RenderTile håndterer overdimensionerede tegninger
Hver zoom-fordobling firfoldiger bitmap-hukommelsen, så cachen behøver et hårdt loft og tiles til overdimensionerede sider

Et fornuftigt standardvalg for en 32-bit Delphi-proces er et bitmap-budget på 256 MB under LRU-udsmidning. På 64-bit kan man skalere med fysisk RAM, men behold under alle omstændigheder et hårdt loft, fordi den fejl, man beskytter sig imod, ikke er ens proces, der crasher. Det er hele maskinen, der tærsker sin swap-fil, mens ens viewer teknisk set bliver ved med at køre, og brugeren undrer sig over, hvorfor alt andet blev langsommere. Et hårdt loft fejler forudsigeligt; en ubegrænset cache fejler ved at tage skrivebordet med sig. Miniaturebilleder fortjener deres egen behandling: render hvert ét én gang ved dets lille målstørrelse, og hold det i en separat pulje, som LRU-logikken aldrig rører. At gengenerere en 120-pixel miniature ved at nedskalere en 60 MB fuldsidebitmap er den mest spildende mulige måde at producere et frimærke på

Nogle enkeltsider overvinder ethvert budget. En E-format teknisk tegning eller et stort kort rendret helt ved 400% er en allokering på flere hundrede megabyte, og ingen udsmidningspolitik gør det acceptabelt. Svaret dér er at stoppe med at rendre hele sider. RenderTile rasteriserer kun regionen ved pixel-offsettet (Left, Top) inden for en side, der nominelt skaleres til PageWidth gange PageHeight, så man kun rendrer det synlige rektangel plus en margin på én tile omkring det for jævn panorering, og man folder tile-offsettene ind i cache-nøglen ved siden af zoom. Hold tile-dimensionerne faste på tværs af dokumentet. Faste tiles betyder, at en DPI-ændring ugyldiggør hele gitteret rent, hvor variable tiles efterlader en jagt på synlige sømme mellem regioner rendret ved lidt forskellige skalaer

To tilstødende funktioner lægger stille og roligt til alt dette. Farvefilterpasseringer såsom gråtoner eller inversion kører efter rendering og producerer hver gang en anden bitmap i fuld størrelse, hvilket fordobler fodaftrykket pr. side for enhver visning, der bruger dem; den omkostning er emnet for farvefiltrering for svagtseende til Delphi-PDF-viewere. Og en viewer, der fremhæver ord under tekst-til-tale, ugyldiggør den rendrede visning ved hvert talt ord, så samspillet mellem fremhævnings-gentegninger og talehastighed betyder mere, end det først virker, som dækket i ord-for-ord TTS-fremhævning

Render-overloadene, de progressive statuskoder og selve viewer-komponenten er dokumenteret på produktsiden for PDFium Component