Technisch artikel

PDFium Delphi-viewer: rendercache en soepele zoomtactiek

Houd de zoomknop ingedrukt in een naïeve PDF-viewer en bekijk de CPU-grafiek. Eén druk op een auto-herhalende zoomknop vuurt een dozijn of meer zoomstappen per seconde af, en als elke stap een volledige kwaliteitsre-render van de zichtbare pagina in gang zet, hopen de renders zich sneller op dan ze voltooid worden. De pagina rasteriseert prima op zichzelf, misschien 180 ms voor een A4-scan, maar u draait nu een dozijn 180 ms-renders tegen werk dat de gebruiker al voorbij is. De viewer loopt vast, één kern pin op 100%, en tegen de tijd dat het scherm bijtrekt staat de gebruiker al stil bij een zoomniveau van vier renders geleden. De remedie is geen snellere rasterizer. Het is een cache die voltooide pagina's onmiddellijk retourneert en een renderlus die werk loslaat zodra het verouderd raakt

PDFium Component levert u de onderdelen voor beide en houdt zich buiten het beleid. U krijgt door de aanroeper beheerde bitmaps, een progressieve renderer die een annuleringstoken accepteert, fit-modi die zoom herberekenen bij resize, en een tiling-call voor pagina's die te groot zijn om in hun geheel te rasteriseren. Wat het opzettelijk niet biedt is de cache zelf, want het juiste verwijderingsbeleid hangt af van uw viewport, de geheugenlimiet van uw platform en hoe uw gebruikers scrollen. Die beslissing is aan u om goed te krijgen, en de gevolgen van het verkeerd krijgen zijn precies de bevriezing en de lekkage

Waar de milliseconden en megabytes naartoe gaan

Zet cijfers op de kosten voordat u iets ontwerpt. Een A4-pagina op 96 DPI is ruwweg 794 bij 1123 pixels, ongeveer 3,5 MB als een 32-bit bitmap. Zoom naar 200% en dat verviervoudigt. Op 400% op een high-DPI-scherm wijst u één pagina-bitmap van 50 tot 60 MB toe en vult u die, en een viewer met doorlopend scrollen houdt meerdere pagina's tegelijk actief. De rasterisatiekosten volgen de uitvoerpixels, dus elke verdubbeling van zoom verviervoudigt zowel de rendertijd als het geheugen samen

Twee gevolgen vallen direct uit die rekenkunde. Een cache waarvan de sleutel het zoomniveau negeert is waardeloos, want juist het gebaar dat hij moet versnellen, zoomen, levert elke keer een nieuwe bitmap op. En een onbegrensde cache zal een 32-bit proces precies op de documenten zonder adresruimte laten raken waar mensen het hardst zoomen: dichte aktescans, technische tekeningen, kaarten in groot formaat. De cache moet correct gesleuteld en stevig begrensd zijn, en geen van beide is optioneel

Wat in de cachesleutel hoort

Een gecachte bitmap is pas veilig te hergebruiken wanneer elke invoer die zijn pixels vormde nog steeds overeenkomt. Dat betekent het paginanummer, de effectieve zoom (ofwel de uitvoerpixelafmetingen), de rotatie, de monitor-DPI en de renderopties die golden toen hij werd geproduceerd. Een pagina gerenderd met reAnnotations is een ander beeld dan dezelfde pagina zonder, en een grijswaaspassing via reGrayscale is weer anders. Laat één van deze uit de sleutel vallen en de bugs zijn voorspelbaar: een annotatie-overlay die achterblijft nadat een reviewer de opmerking verwijdert, of een pagina die wazig wordt zodra een gebruiker het venster van een laptopscherm naar een externe 4K-monitor sleept en de DPI onder een verouderde bitmap verandert

PDFium rendercache-opzoeking in een Delphi-viewer waarbij de cache-sleutel pagina, zoom, rotatie, monitor-DPI en renderopties combineert, een treffer de bitmap teruggeeft in microseconden, en verwijdering elke bitmap vrijgeeft die hij laat vallen
De cache-sleutel bestrijkt elke invoer die de pixels vormt, en verwijdering geeft de bitmaps vrij die hij laat vallen
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);   // de cache is nu eigenaar van deze bitmap
end;

Bij een hit retourneert dit in microseconden, wat het hele doel is. De moeilijkere vraag is wat er gebeurt met de bitmaps die uit de cache vallen, en dat blijkt een vraag over wie ze bezit

Wie de bitmap vrijgeeft

De functievorm van RenderPage retourneert een TBitmap die de aanroeper bezit. Bij een eenmalige export is dat eigendom voor de hand liggend en makkelijk te eren. Binnen een cache wordt het de meest voorkomende lekkage in Delphi PDF-viewers, want het woordenboek houdt nu de enige referentie naar elke bitmap, en een gewone TDictionary bevrijdt sleutels en waarden voor u alleen als het managed types zijn. Een TBitmap is dat niet. Verwijder een item zonder Free aan te roepen en de pixels blijven toegewezen zonder dat er iets naar wijst

Dat dit door de mazen glipt is timing. Een rooktest van tien minuten zoomt nooit genoeg verschillende pagina's om het op te merken; de lekkage toont zich pas nadat iemand een lang document een paar uur heeft gescrold en gezoomd, waarna het proces honderden verweesde paginabitmap vasthoudt en de machine begint te paging. Daarom hoort verwijdering in de eerste versie van de cache, niet in een latere. Begrens de cache op geschatte bytes, berekend als breedte keer hoogte keer vier, verwijder de minst recent gebruikte pagina's die buiten de viewport en het prefetch-venster vallen, en bevrijd elke bitmap terwijl u hem verwijdert. Voor tekenbewerkingen die echt tijdelijk zijn, laten de overloads die renderen naar een door de aanroeper verstrekte TBitmap of rechtstreeks op een HDC u de eigendomsdans helemaal overslaan. Afdrukvoorbeeld is het voor de hand liggende geval, want u rendert elk vel één keer en cachen koopt niets

Progressief renderen en eerlijke annulering

De gewone RenderPage-overloads blokkeren tot de pagina klaar is, wat precies het gedrag is dat u niet wilt terwijl de gebruiker nog met de zoomknop bezig is. Daarvoor grijpt u naar RenderPageProgressive. Hij accepteert een IPdfCancellationToken en retourneert prsDone, prsCancelled of prsFailed. Het gedragdetail dat mensen verrast is dat annulering niet onmiddellijk is. Het token wordt gepeild op chunk-grenzen binnen de render, dus een token dat u signaleert midden in een chunk werkt pas wanneer die chunk klaar is. Op een complexe pagina loopt de vertraging tussen vragen en stoppen op tot tientallen milliseconden. Ontwerp om die kloof heen in plaats van hem weg te wensen: annuleer het vorige token zodra een nieuwe zoomwaarde binnenkomt, maar ga er niet van uit dat de oude render stopt op het moment dat u erom vraagt

PDFium progressieve renderingtijdlijn in Delphi waarbij elke nieuwe zoomaanvraag het vorige token annuleert, annulering landt op een chunkgrens, verouderde renders prsCancelled teruggeven, en de laatste poging prsDone teruggeeft
Elk nieuw zoomverzoek annuleert de vorige render-token, en annulering landt op een chunkgrens
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // breek de vorige lopende render af
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

Tijdens interactie is prsCancelled de normale uitkomst, niet de uitzonderlijke. De meeste renders die een zoomgebaar in gang zet worden vervangen voordat ze klaar zijn, dus behandel annulering als routine en laat het resultaat geruisloos vallen. Een renderqueue die elke annulering als waarschuwing logt, begraaft de ene storing die er echt toe doet onder duizenden regels ruis. Om te voorkomen dat het scherm er dood uitziet terwijl de echte render draait, koppel het progressieve pad aan een goedkope stand-in: schaal de vorige gecachte bitmap naar de nieuwe zoom en presenteer die onmiddellijk. Hij ziet er een honderdtal milliseconden of twee zacht uit, maar leest als onmiddellijk, en hij geeft de full-kwaliteitsrender de tijd die hij nodig heeft om af te ronden of geannuleerd te worden door het volgende gebaar

De fit-modus die zoom geruisloos uitzet

De eigenschap FitMode van een viewer, ingesteld op pfmFitPage of pfmFitWidth, herberekent zoom bij elke resize zodat de pagina blijft passen terwijl het venster verandert. De adder onder het gras is dat Zoom direct toewijzen FitMode terugzet op pfmNone. Als standaard is dat correct: een gebruiker die bewust 150% typt wil niet dat de volgende vensterresize het wegsmijt. Maar het verrast iedereen die een zoom-in-knop bekabelt als Zoom := Zoom * 1.25 en dan niet begrijpt waarom fit-to-width na de eerste klik niet meer reageert. Als uw werkbalk zowel expliciete zoom als fit-modi aanbiedt, moet u de laatste fit-keuze van de gebruiker zelf onthouden en haar opnieuw toewijzen wanneer ze opnieuw op de fit-knop drukken. De component herstelt geen modus die een zoomtoewijzing zojuist heeft gewist, en dat hoort ook niet

Een geheugenbudget dat u kunt verdedigen

Een budget dat u kunt opschrijven is een budget dat u kunt beargumenteren in een code-review, dus vertrek vanaf een concreet scenario. Stel dat doorlopend scrollen de zichtbare pagina plus één vooraf opgehaalde pagina boven en onder actief houdt, naast een miniatuurstrip. Op 100% op een 96-DPI-scherm komen die drie bitmaps op volledige grootte uit op ongeveer 3,5 MB per stuk, wat niets is. Op 300% op een 4K-scherm zijn dezelfde drie bitmaps ruwweg 30 MB per stuk, en dat voordat de cache één historische pagina heeft behouden. De groei zit in het gebaar, niet in het document

PDFium bitmapgeheugenrekenkunde voor een Delphi-viewer waarbij elke zoomverdubbeling het paginageheugen verviervoudigt, continuous scroll drie pagina's levend houdt, een gekapst LRU-budget de cache verdedigt, en RenderTile te grote tekeningen afhandelt
Elke zoomverdubbeling verviervoudigt bitmapgeheugen, dus de cache heeft een hard plafond en tegels nodig voor te grote pagina's

Een verantwoorde standaard voor een 32-bit Delphi-proces is een bitmapbudget van 256 MB onder LRU-verwijdering. Op 64-bit kunt u schalen met fysiek RAM, maar houd toch een harde bovengrens aan, want de storing die u verzekert is niet dat uw proces crasht. Het is dat de hele machine zijn pagefile laat thrashen terwijl uw viewer technisch blijft draaien en de gebruiker zich afvraagt waarom alles anders trager werd. Een harde grens faalt voorspelbaar; een onbegrensde cache faalt door het bureaublad met zich mee te nemen. Miniaturen verdienen een eigen behandeling: rendeer elke miniatuur één keer op zijn kleine doelgrootte en houd hem in een aparte pool die de LRU-logica nooit aanraakt. Een miniatuur van 120 pixels regenereren door een full-pagina-bitmap van 60 MB te verkleinen is de meest verspillende manier om een postzegel te produceren

Sommige afzonderlijke pagina's verslaan elk budget. Een technische tekening in E-formaat of een grote kaart in zijn geheel gerenderd op 400% is een toewijzing van meerdere honderden megabytes, en geen verwijderingsbeleid maakt dat acceptabel. Het antwoord daar is stoppen met hele pagina's renderen. RenderTile rasteriseert alleen de regio op pixeloffset (Left, Top) binnen een pagina die nominaal is geschaald naar PageWidth bij PageHeight, dus u rendert alleen de zichtbare rechthoek plus een marge van één tegel eromheen voor soepel pannen, en u vouwt de tegeloffsets in de cachesleutel samen met zoom. Houd de tegelafmetingen vast over het hele document. Vaste tegels betekenen dat een DPI-wijziging het hele raster netjes ongeldig maakt, terwijl variabele tegels u achter zichtbare naden aan laten jagen tussen regio's die op iets verschillende schaal zijn gerenderd

Twee aangrenzende features voegen geruisloos aan dit alles toe. Kleurfilterpassen zoals grijswaarden of inversie draaien na het renderen en produceren telkens een tweede bitmap op volledige grootte, wat de voetafdruk per pagina van elke weergave die ze gebruikt verdubbelt; die kosten zijn het onderwerp van kleurfiltering voor slechtzienden in Delphi PDF-viewers. En een viewer die woorden markeert tijdens tekst-naar-spraak ongeldig de gerenderde weergave bij elk uitgesproken woord, dus de interactie tussen herbouw van markeringen en spreesnelheid doet er meer toe dan op het eerste gezicht lijkt, zoals behandeld in woord-voor-woord TTS-markering

De rendering-overloads, de progressieve statuscodes en de viewer-component zelf zijn gedocumenteerd op de productpagina voor PDFium Component