Teknisk artikel

Avbrytbar progressiv PDF-rendering i Delphi (PDFium)

De flesta PDF-sidor rastreras på några millisekunder och du tänker aldrig på det. Sedan öppnar en användare en A1-konstruktionsritning, en sida packad med tiotusentals vektorstreck, eller en affisch fylld med transparensgrupper och mjuka masker, och det enda anropet som målar den tar två eller tre sekunder. Om det anropet körs på UI-tråden, slutar fönstret att rita om, titelfältet blir grått, och operativsystemet erbjuder att stänga applikationen. Arbetet är legitimt. Sidan behöver verkligen så lång tid. Felet är att renderingen är ett odelbart blockerande anrop utan möjlighet att hämta andan och ingen möjlighet att sluta

Den här artikeln handlar om exakt ett av de två problemen: att avbryta en lång en-sidig rendering utan att frysa användargränssnittet. Användaren klickade på nästa sida, eller zoomade, eller stängde dokumentet, och den pågående renderingen är nu bortkastat arbete som bör avslutas vid nästa tillfälle snarare än att köras till slutet. Att göra skrollning och zoomning mjukare genom att cacha det som redan rastrerats är en separat fråga med sin egen design, som täcks i den medföljande artikeln som är länkad i slutet. Här är den enda frågan hur man får en progressiv rendering att svara på en avbryt-begäran snabbt och rent

Det progressiva renderings-API:et som PDFium redan levererar med

PDFium förutsåg frys-hälften av problemet. Vid sidan av det direktkörande FPDF_RenderPageBitmap, exponerar den en progressiv variant som delar upp en sida i bitar av arbete. Du anropar FPDF_RenderPageBitmap_Start en gång för att sätta upp renderingen mot en målbitmapp, sedan anropar du FPDF_RenderPage_Continue upprepade gånger. Varje Continue rastrerar en avgränsad bit och returnerar en status. FPDF_RENDER_TOBECONTINUED betyder att det finns mer att göra, FPDF_RENDER_DONE betyder att sidan är klar, och FPDF_RENDER_FAILED betyder att den stannade vid ett fel. När loopen slutar anropar du FPDF_RenderPage_Close för att frigöra det per-sida progressiva tillståndet. Eftersom kontrollen återgår till din kod mellan bitarna kan du pumpa meddelanden, uppdatera en förloppsindikator, eller kontrollera om arbetet fortfarande är önskat

Den mekanism PDFium tillhandahåller för att besluta när det ska ge vika är en callback-struct med namnet IFSDK_PAUSE. Du ger den till Start och till varje Continue. Efter varje bit anropar PDFium sin NeedToPauseNow funktionspekare, och om den returnerar ett värde som inte är noll stannar det aktuella Continue tidigt och lämnar tillbaka kontrollen med FPDF_RENDER_TOBECONTINUED. Structen har också ett version-fält, vilket måste sättas till 1, och en fri user-pekare som PDFium aldrig rör och skickar vidare orörd. Den orörda pekaren är hela kärnan i den design som följer

Återanvända paus som avbryt

Det ursprungliga syftet med NeedToPauseNow är tidsuppdelning. Returnera icke-noll när din rambudget är förbrukad, returnera noll för att fortsätta rendera, och PDFium pausar så att du kan göra något annat innan du återupptar samma rendering. PDFium Component återanvänder samma signal för ett annat verb. Istället för att svara "bör jag pausa och låta dig återuppta," svarar callbacken "har detta arbete avbrutits." De två överensstämmer rent med varandra på grund av vad loopen gör när den ser flaggan. En genuin paus förväntar sig ett senare Continue; det gör inte ett avbrytande. När den anropande loopen observerar att token är avbruten stänger den renderingskontexten och anropar aldrig Continue igen, så samma icke-noll-retur som PDFium läser som "stanna den här biten" blir i praktiken "stanna för gott."

Avbrytande uttrycks genom ett gränssnitt, IPdfCancellationToken, vars IsCancelled egenskap växlar från falskt till sant när en annan del av programmet ber renderingen att sluta. Bron mellan det Pascal-gränssnittet och PDFiums C-callback är en enda pekare. Tokens gränssnittsreferens skrivs till IFSDK_PAUSE.user, och en statisk cdecl callback läser tillbaka den och frågar den. Detta är det klassiska problemet med att låta ett C-bibliotek anropa tillbaka in i Pascal: callbacken måste vara en vanlig funktion med C anropskonvention, inte en metod, eftersom PDFium lagrar och anropar en bar funktionspekare som inte vet något om Pascal-objekt 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;

Callbacken återställer token genom att casta pThis^.user tillbaka till gränssnittstypen och läser IsCancelled. Ingenting i den allokerar, låser eller blockerar, vilket är viktigt eftersom PDFium anropar den på renderingstråden efter varje bit och allt arbete som görs här läggs till kostnaden för själva renderingen. Skyddet mot en nil struct eller ett nil user-fält betyder att samma funktion är säker att installera även på en rendering som aldrig gavs en riktig token

Att hålla token vid liv genom loopen

Att casta en gränssnittspekare via en rå Pointer och tillbaka är där livstidbuggar föds. En IInterface i Delphi är referensräknad, och räkningen flyttas bara när kompilatorn kan se en variabel av gränssnittstyp tilldelas. Att lagra token uteslutande som en bar pekare i IFSDK_PAUSE.user skulle gömma den helt från referensräknaren. Om den enda andra referensen till det tokenet försvann ur sikte medan Continue-loopen fortfarande kördes, skulle objektet frigöras under callbacken, och nästa bit skulle avreferera en hängande pekare

Det är därför deskriptorn är en record som håller två saker, inte en. Pause fältet är structen som PDFium läser. Token fältet är en riktig gränssnittstyp-referens som kompilatorn räknar, och det existerar av ingen annan anledning än att fästa token i minnet så länge recorden lever. Recorden är en lokal variabel på stacken för renderingsrutinen, så den förblir giltig för hela varaktigheten av loopen och rivs ner endast när rutinen avslutas. Den bara pekaren i user och den räknade referensen i Token namnger samma objekt; det ena är vad PDFium kan läsa, det andra är vad som hindrar det objektet från att samlas in

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);

Att stänga renderingskontexten oavsett hur loopen slutar

Varje anrop till FPDF_RenderPageBitmap_Start allokerar ett progressivt tillstånd som PDFium associerar med sidan, och det tillståndet frigörs endast av FPDF_RenderPage_Close. Det finns tre vägar ut ur drivloopen. Sidan blir klar och den sista statusen är FPDF_RENDER_DONE. Tokenet löser ut och loopen avslutas tidigt för att rapportera ett avbrott. Något misslyckas och statusen är FPDF_RENDER_FAILED. Alla tre måste anropa Close, och avbrottsvägen är lättast att göra fel på, eftersom den naturliga formen "se avbryt, bryt ur" tenderar att hoppa över städningen på sin väg mot utgången. Att lämna Close onådd läcker det per-sida tillståndet, och en visare som låter användaren avbryta rendering efter rendering skulle samla den läckan för varje avbruten sida

Den robusta formen sätter loopen och resultatklassificeringen inuti en try och FPDF_RenderPage_Close i det matchande finally. Målbitmappen förstörs i samma block. Ett avbrott kan lämna loopen genom en tidig Exit och finally körs fortfarande, så det finns exakt en plats som frigör det progressiva tillståndet och det kan inte förbigås

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;

Loopen kontrollerar tokenet innan varje Continue såväl som att den förlitar sig på callbacken inuti den. Callbacken förkortar den aktuella biten; loop-kontrollen stoppar nästa från att starta. Tillsammans begränsar de hur lång tid ett avbrott tar för att träda i kraft till ungefär varaktigheten av en bit

Tre utfall, och vad bitmappen innehåller efter ett avbrott

Den publika ingången är TPdf.RenderPageProgressive, och den returnerar en TPdfProgressiveStatus som är en av prsDone, prsCancelled eller prsFailed. Värdena speglar PDFiums FPDF_RENDER_*-konstanter i Pascal-idiom men viker in avbrottsfallet som ett förstklassigt resultat istället för ett fel

Punkten som fångar folk är vad målbitmappen innehåller efter prsCancelled. Den är inte tom. PDFium renderar progressivt in i samma bitmapp bit efter bit, så när ett avbrott stannar loopen innehåller bitmappen vad som än målades fram till det ögonblicket, vilket är en delvis bild: vissa band är klara, resten visar fortfarande fyllningsfärgen. Om det partiella resultatet är användbart beror på anroparen. En visare som är på väg att kasta bort bitmappen eftersom användaren navigerade någon annanstans kan helt enkelt ignorera det. En visare som vill visa en låg-kostnads-förhandsvisning kan behålla det. Vad du inte får göra är att anta att prsCancelled innebär en tom eller odefinierad bitmapp; det innebär en sanningsenlig ögonblicksbild av en ofullbordad 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 och en gren-fri callback-väg

Avbrytande är ett val (opt-in). En anropare som bara vill ha progressiv rendering för meddelande-pumps-fördelen, utan avsikt att avbryta, bör kunna skicka nil för tokenet. Det naiva sättet att stödja det är att strö "om en token tillhandahölls" kontroller genom callbacken och loopen, vilket innebär en gren på varje bit och en callback som måste hantera både ett riktigt token och dess frånvaro

Implementationen undviker det genom att byta ut en singelton när anroparen skickar ingenting. En nil token byts ut mot PdfNoCancellationToken, ett gränssnitt vars IsCancelled alltid är falskt. Från det ögonblicket har callbacken och loopen ett token att fråga i varje fall, så ingetdera behöver en nil-kontroll och ingetdera behöver en särskild väg. Det aldrig-avbrytande tokenet svarar helt enkelt alltid falskt, callbacken returnerar alltid noll, och renderingen körs till slutet exakt som en icke-avbrytbar skulle göra. Valfritt beteende modelleras som ett token som aldrig utlöses istället för som frånvaron av ett token, vilket håller den heta vägen 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 som framträder är liten och värd att upprepa, eftersom den är den återanvändbara delen. Ett C-bibliotek som stöder en callback ger dig exakt en kanal att skicka tillstånd in i den callbacken, den opaka användarpekaren. Sätt en räknad Pascal gränssnittsreferens bakom den pekaren, håll en andra riktig referens vid liv bredvid structen så att objektet inte kan samlas in mitt i anropet, och läs ut gränssnittet tillbaka inuti en statisk cdecl funktion. Lägg hela drivloopen i en try och frigör den inbyggda kontexten i finally. Samma mall förs över till valfri progressiv eller callback-driven PDFium-operation där Pascal-kod måste ha kontroll över livstiden medan C håller en pekare

Avbrytande är bara ena halvan av en responsiv visare. Den andra halvan är att inte rendera om sidor du redan ritat, och att hålla zoom och skroll mjukt genom att servera cachade bitmappar, vilket behandlas i vår artikel om renderings-cache och zoom-prestanda. För hur den avbrytbara renderingen passar in i en komplett visare tillsammans med navigering, val och sökning, se bygga en funktionsrik PDF-visare med PDFium Component. Den progressiva renderingen som beskrivs här levereras som en del av PDFium Component för Delphi och Lazarus tillsammans med laddnings-, renderings- och formulär-API:erna som behandlas på andra ställen i den här bloggen