De fleste PDF-sider rasteriseres på få millisekunder, og du tænker aldrig over det. Så åbner en bruger en A1 ingeniørtegning, en side spækket med titusindvis af vektorstrøg, eller en plakat overfyldt med gennemsigtighedsgrupper (transparency groups) og bløde masker (soft masks), og det ene kald, der maler den, tager to eller tre sekunder. Hvis det kald kører på UI-tråden, holder vinduet op med at gentegne (repainting), titellinjen bliver grå, og operativsystemet tilbyder at dræbe (kill) applikationen. Arbejdet er legitimt. Siden har virkelig brug for så lang tid. Fejlen (The defect) er, at renderingen er ét udeleligt blokerende kald uden nogen måde at komme op efter luft (come up for air) og ingen måde at stoppe på
Denne artikel handler om præcis et af de to problemer: annullering af en lang enkeltsides-rendering uden at fryse brugergrænsefladen. Brugeren klikkede på næste side, eller zoomede, eller lukkede dokumentet, og renderingen, der er i gang (in flight), er nu spildt arbejde, som burde slutte ved næste lejlighed i stedet for at køre til ende. Udglatning (Smoothing) af rulning (scroll) og zoom ved at cache det, der allerede blev rasteriseret, er en separat bekymring med sit eget design, dækket i den ledsagende artikel, der er linket til sidst. Her er det eneste spørgsmål, hvordan man får en progressiv rendering til at besvare en annulleringsanmodning (cancel request) hurtigt og rent
Den progressive render-API, PDFium allerede leveres med
PDFium forudså den frysende halvdel af problemet. Ved siden af den engangs (one-shot) FPDF_RenderPageBitmap, udstiller den en progressiv variant, der opdeler en side i bidder af arbejde (chunks of work). Du kalder FPDF_RenderPageBitmap_Start én gang for at opsætte renderingen mod en destinations-bitmap, og kalder derefter FPDF_RenderPage_Continue gentagne gange. Hver Continue rasteriserer en afgrænset skive (bounded slice) og returnerer en status. FPDF_RENDER_TOBECONTINUED betyder, at der er mere at gøre, FPDF_RENDER_DONE betyder, at siden er færdig, og FPDF_RENDER_FAILED betyder, at den stoppede på en fejl. Når løkken (loop) slutter, kalder du FPDF_RenderPage_Close for at frigive den pr.-side progressive tilstand (per-page progressive state). Fordi kontrollen vender tilbage til din kode mellem skiverne, kan du pumpe meddelelser (pump messages), opdatere en fremskridtsindikator (progress indicator) eller kontrollere, om arbejdet stadig er ønsket
Den mekanisme, PDFium leverer til at beslutte, hvornår der skal gives efter (yield), er en callback-struct ved navn IFSDK_PAUSE. Du overleverer den til Start og til hver Continue. Efter hver bid kalder PDFium dens NeedToPauseNow funktionspeger (function pointer), og hvis den returnerer en ikke-nul-værdi, stopper den aktuelle Continue tidligt og giver kontrollen tilbage med FPDF_RENDER_TOBECONTINUED. Struct'en bærer også et version-felt, som skal indstilles til 1, og en friformet (free-form) user-peger, som PDFium aldrig rører og sender igennem urørt. Den urørte peger er hele hængslet i det design, der følger
Genanvendelse (Repurposing) af pause som annullering
Den oprindelige hensigt med NeedToPauseNow er tidsopdeling (time-slicing). Returner ikke-nul, når dit frame-budget er brugt, returner nul for at fortsætte renderingen, og PDFium pauser, så du kan gøre noget andet, før du genoptager den samme rendering. PDFium-komponenten genbruger det samme signal til et andet udsagnsord. I stedet for at svare "skal jeg pause og lade dig genoptage," svarer callback'et "er dette arbejde blevet annulleret". De to afbildes (map) rent på hinanden på grund af, hvad løkken gør, når den ser flaget. En ægte pause forventer en senere Continue; en annullering gør ikke. Når den kaldende løkke observerer, at tokenet er annulleret, lukker den render-konteksten og kalder aldrig Continue igen, så den samme ikke-nul-retur, som PDFium læser som "stop denne bid", bliver i virkeligheden "stop for altid"
Annullering udtrykkes gennem en grænseflade (interface), IPdfCancellationToken, hvis IsCancelled-egenskab vipper fra falsk (false) til sand (true), når en anden del af programmet beder renderingen om at stoppe. Broen mellem den Pascal-grænseflade og PDFiums C-callback er en enkelt peger. Tokenets grænsefladereference skrives ind i IFSDK_PAUSE.user, og et statisk cdecl-callback læser den tilbage ud og forespørger den (queries it). Dette er det klassiske problem med at lade et C-bibliotek kalde tilbage ind i Pascal: callback'et skal være en almindelig funktion med C-kaldkonvention (calling convention), ikke en metode, fordi PDFium gemmer og påkalder (invokes) en bar funktionspeger, der intet ved om Pascal-objekter eller Self
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // PDFium reads this; .user holds the token
Token: IPdfCancellationToken; // strong ref keeps the token alive
end;
function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
Token: IPdfCancellationToken;
begin
Result := 0;
if (pThis = nil) or (pThis^.user = nil) then
Exit;
Token := IPdfCancellationToken(pThis^.user);
if Token.IsCancelled then
Result := 1; // non-zero: PDFium stops this chunk
end;
Callback'et genvinder (recovers) tokenet ved at caste (casting) pThis^.user tilbage til grænsefladetypen og læser IsCancelled. Intet i det allokerer, låser eller blokerer, hvilket betyder noget, fordi PDFium kalder det på renderings-tråden (the rendering thread) efter hver bid, og ethvert arbejde udført her tilføjes til omkostningerne ved selve renderingen. Vagten mod en nil struct eller et nil user felt betyder, at den samme funktion er sikker at installere selv på en rendering, der aldrig fik et rigtigt token
At holde tokenet i live på tværs af løkken
At caste en grænseflade-peger gennem en rå Pointer og tilbage er der, hvor livstids-fejl (lifetime bugs) bliver født. En IInterface i Delphi er reference-talt (reference counted), og tællingen (the count) bevæger sig kun, når compileren kan se en grænseflade-typet variabel blive tildelt. At gemme tokenet udelukkende som en bar peger inde i IFSDK_PAUSE.user ville skjule det fuldstændigt fra reference-tælleren. Hvis den eneste anden reference til det token gik ud af scope (went out of scope), mens Continue-løkken stadig kørte, ville objektet blive frigivet under callback'et, og den næste bid ville dereference (dereference) en dinglende peger (dangling pointer)
Det er grunden til, at deskriptoren er en post (record), der rummer to ting, ikke én. Pause-feltet er struct'en, PDFium læser. Token-feltet er en ægte grænseflade-typet (interface-typed) reference, som compileren tæller, og det eksisterer af ingen anden grund end at fastgøre (pin) tokenet i hukommelsen, så længe posten (the record) lever. Posten er en lokal variabel på stakken (stack) i render-rutinen, så den forbliver gyldig i hele løkkens varighed og rives kun ned (torn down), når rutinen afsluttes. Den bare peger i user og den talte reference i Token navngiver det samme objekt; den ene er, hvad PDFium kan læse, den anden er det, der forhindrer objektet i at blive indsamlet (collected)
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// ... choose EffectiveToken ...
// Strong ref first, then publish the same object to PDFium via .user.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
Lukning af render-konteksten, uanset hvordan løkken slutter
Hvert kald til FPDF_RenderPageBitmap_Start allokerer progressiv tilstand, som PDFium knytter til siden, og den tilstand frigives kun af FPDF_RenderPage_Close. Der er tre veje ud af drive-løkken (the drive loop). Siden bliver færdig, og den sidste status er FPDF_RENDER_DONE. Tokenet udløses (trips), og løkken afsluttes tidligt og rapporterer annullering. Noget slår fejl, og status er FPDF_RENDER_FAILED. Alle tre skal kalde Close, og annulleringsstien (the cancellation path) er den letteste at tage fejl af, fordi den naturlige form af "se annullering, bryd ud" (see cancel, break out) har tendens til at springe oprydning (cleanup) over på vej til udgangen (the exit). At efterlade Close unået (unreached) lækker den pr.-side tilstand, og en fremviser, der lader brugeren annullere rendering efter rendering, ville akkumulere den lækage på hver afbrudte (aborted) side
Den robuste form sætter løkken og resultatklassificeringen (result classification) inde i en try og FPDF_RenderPage_Close i den matchende finally. Destinations-bitmap'en ødelægges i den samme blok. Annullering kan forlade løkken gennem en tidlig Exit, og finally kører stadig, så der er nøjagtig ét sted, der frigør den progressive tilstand, og det kan ikke omgås (bypassed)
Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
while Status = FPDF_RENDER_TOBECONTINUED do
begin
if EffectiveToken.IsCancelled then
begin
Result := prsCancelled;
Exit;
end;
Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
end;
if EffectiveToken.IsCancelled then
Result := prsCancelled
else if Status = FPDF_RENDER_DONE then
Result := prsDone
else
Result := prsFailed;
finally
// Frees the progressive state Start allocated; mandatory on every path.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
Løkken tjekker tokenet før hver Continue såvel som at stole på callback'et indeni den. Callback'et forkorter den aktuelle bid; løkketjekket stopper den næste fra at starte. Sammen begrænser (bound) de, hvor lang tid en annullering tager for at træde i kraft, til groft sagt varigheden af én bid
Tre udfald, og hvad bitmap'en indeholder efter en annullering
Det offentlige indgangspunkt (public entry point) er TPdf.RenderPageProgressive, og det returnerer en TPdfProgressiveStatus, der er en af prsDone, prsCancelled eller prsFailed. Værdierne spejler (mirror) PDFiums FPDF_RENDER_* konstanter i Pascal-idiom, men folder annulleringstilfældet ind som et førsteklasses (first-class) resultat i stedet for en fejl
Pointen, der fanger folk, er, hvad destinations-bitmap'en indeholder efter prsCancelled. Den er ikke tom. PDFium rendererer progressivt ind i den samme bitmap bid efter bid, så når en annullering stopper løkken, rummer bitmap'en det, der blev malet indtil det øjeblik, hvilket er et delvist billede: nogle bånd (bands) er færdige, resten viser stadig fyldfarven (the fill colour). Om det delvise resultat er nyttigt afhænger af kalderen. En fremviser, der er ved at smide bitmap'en væk, fordi brugeren navigerede et andet sted hen, kan simpelthen ignorere det. En fremviser, der ønsker at vise en lavpris forhåndsvisning (low-cost preview), kan beholde det. Hvad du ikke må gøre, er at antage, at prsCancelled indebærer en tom eller udefineret bitmap; det indebærer et sandfærdigt (truthful) øjebliksbillede af en ufærdig rendering
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// Token starts un-cancelled; flip Token.IsCancelled from elsewhere
// (a UI action, a navigation event) to abort the render in flight.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // fully rendered
prsCancelled: ; // partial bitmap, usually discarded
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
Nil-tokenet og en gren-fri (branch-free) callback-sti
Annullering er tilvalg (opt-in). En kalder, der bare ønsker progressiv rendering for meddelelses-pumpnings (message-pumping) fordelen, uden hensigt om at afbryde (aborting), bør kunne videregive (pass) nil for tokenet. Den naive måde at understøtte det på er at sprede "hvis et token blev leveret"-tjek (checks) gennem callback'et og løkken, hvilket betyder en forgrening (a branch) på hver bid og et callback, der skal håndtere både et rigtigt token og dets fravær
Implementeringen undgår det ved at erstatte med en singleton, når kalderen intet sender (passes nothing). Et nil-token byttes ud med PdfNoCancellationToken, en grænseflade hvis IsCancelled altid er falsk. Fra det punkt har callback'et og løkken et token at forespørge i alle tilfælde, så ingen af dem har brug for et nil-tjek, og ingen af dem har brug for en speciel sti. Aldrig-annuller (never-cancel) tokenet svarer simpelthen altid falsk, callback'et returnerer altid nul, og renderingen kører til ende nøjagtigt, som en ikke-annullerbar en ville. Valgfri opførsel er modelleret som et token, der aldrig udløses (fires), snarere end som fraværet af et token, hvilket holder den varme sti (hot path) ensartet (uniform)
// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
Formen (The shape), der dukker op, er lille og værd at gentage, for den er den genanvendelige del. Et C-bibliotek, der understøtter et callback, giver dig nøjagtig én kanal til at sende tilstand ind i det callback, den uigennemsigtige (opaque) user-peger. Sæt en talt (counted) Pascal-grænsefladereference bag den peger, hold en anden ægte reference i live ved siden af struct'en, så objektet ikke kan indsamles midt i et kald, og læs grænsefladen tilbage ud inde i en statisk cdecl-funktion. Pak (Wrap) hele drive-løkken ind i en try og frigør den native kontekst i finally. Den samme skabelon (template) overføres til enhver progressiv eller callback-drevet PDFium-operation, hvor Pascal-kode skal forblive i kontrol over livstiden (lifetime), mens C holder en peger
Annullering er kun den ene halvdel af en responsiv fremviser. Den anden halvdel er ikke at gen-renderere (re-rendering) sider, du allerede har tegnet, og at holde zoom og scroll glidende (smooth) ved at servere cachede bitmaps, hvilket er dækket i vores artikel om render-caching og zoom-ydeevne. For at se hvordan den annullerbare rendering passer ind i en komplet fremviser ved siden af navigation, valg (selection) og søgning, se opbygning af en funktionsrig PDF-fremviser med PDFium-komponenten. Den progressive rendering, der er beskrevet her, leveres som en del af PDFium-komponenten til Delphi og Lazarus sammen med loading-, renderings- og form-API'erne dækket andre steder på denne blog