Articolo tecnico

Rendering PDF progressivo annullabile in Delphi (PDFium)

La maggior parte delle pagine PDF viene rasterizzata in pochi millisecondi e non ci pensate nemmeno. Poi un utente apre un disegno tecnico A1, una pagina stipata di decine di migliaia di tratti vettoriali, oppure un manifesto affollato di gruppi di trasparenza e maschere morbide, e la singola chiamata che lo dipinge impiega due o tre secondi. Se quella chiamata gira sul thread della UI, la finestra smette di ridisegnarsi, la barra del titolo diventa grigia e il sistema operativo si offre di terminare l'applicazione. Il lavoro è legittimo. La pagina ha davvero bisogno di tutto quel tempo. Il difetto è che il rendering è una singola chiamata bloccante indivisibile, senza alcun modo di riprendere fiato e senza alcun modo di fermarsi

Questo articolo riguarda esattamente uno di quei due problemi: annullare un lungo rendering di pagina singola senza congelare la UI. L'utente ha fatto clic sulla pagina successiva, o ha ingrandito, o ha chiuso il documento, e il rendering in volo è ormai lavoro sprecato che dovrebbe finire alla prima occasione anziché arrivare in fondo. Rendere fluidi scorrimento e zoom mettendo in cache ciò che era già stato rasterizzato è una questione separata con un proprio progetto, trattata nell'articolo compagno collegato alla fine. Qui l'unica domanda è come far sì che un rendering progressivo risponda a una richiesta di annullamento in fretta e in modo pulito

La API di rendering progressivo che PDFium già offre

PDFium aveva previsto la metà del problema legata al congelamento. Accanto alla FPDF_RenderPageBitmap in un colpo solo, espone una variante progressiva che spezza una pagina in blocchi di lavoro. Chiamate FPDF_RenderPageBitmap_Start una volta per predisporre il rendering verso una bitmap di destinazione, poi chiamate ripetutamente FPDF_RenderPage_Continue. Ogni Continue rasterizza una fetta limitata e restituisce uno stato. FPDF_RENDER_TOBECONTINUED significa che c'è altro da fare, FPDF_RENDER_DONE significa che la pagina è finita e FPDF_RENDER_FAILED significa che si è fermata su un errore. Quando il ciclo termina chiamate FPDF_RenderPage_Close per rilasciare lo stato progressivo di quella pagina. Poiché il controllo torna al vostro codice fra una fetta e l'altra, potete pompare i messaggi, aggiornare un indicatore di avanzamento o verificare se il lavoro serva ancora

Diagramma del ciclo di rendering progressivo di PDFium con FPDF_RenderPageBitmap_Start, chiamate ripetute a FPDF_RenderPage_Continue e una FPDF_RenderPage_Close obbligatoria in Delphi
Ogni Continue rasterizza una fetta limitata e restituisce uno stato, quindi il controllo torna al codice Delphi fra una fetta e l'altra mentre Close rilascia lo stato progressivo su ogni via di uscita

Il meccanismo che PDFium fornisce per decidere quando cedere il passo è una struttura di callback chiamata IFSDK_PAUSE. La consegnate a Start e a ogni Continue. Dopo ogni blocco PDFium chiama il suo puntatore a funzione NeedToPauseNow, e se questo restituisce un valore diverso da zero, la Continue corrente si ferma in anticipo e restituisce il controllo con FPDF_RENDER_TOBECONTINUED. La struttura porta anche un campo version, che deve valere 1, e un puntatore user a forma libera che PDFium non tocca mai e ripassa intatto. Quel puntatore intatto è tutto il cardine del progetto che segue

Riusare la pausa come annullamento

L'intento originale di NeedToPauseNow è la suddivisione del tempo. Restituite un valore diverso da zero quando il vostro budget di frame è esaurito, restituite zero per continuare a disegnare, e PDFium mette in pausa così che possiate fare altro prima di riprendere lo stesso rendering. PDFium Component riusa quello stesso segnale per un verbo diverso. Invece di rispondere alla domanda "devo fermarmi e lasciarti riprendere", il callback risponde a "questo lavoro è stato annullato". I due si sovrappongono in modo pulito per via di ciò che il ciclo fa quando vede il flag. Una pausa autentica si aspetta una Continue successiva; un annullamento no. Una volta che il ciclo chiamante osserva che il token è annullato, chiude il contesto di rendering e non chiama mai più Continue, quindi lo stesso ritorno diverso da zero che PDFium legge come "ferma questo blocco" diventa, di fatto, "fermati per sempre."

L'annullamento è espresso attraverso una interfaccia, IPdfCancellationToken, la cui proprietà IsCancelled passa da falso a vero quando qualche altra parte del programma chiede che il rendering si fermi. Il ponte fra quella interfaccia Pascal e il callback C di PDFium è un solo puntatore. Il riferimento di interfaccia del token viene scritto in IFSDK_PAUSE.user, e un callback statico cdecl lo rilegge e lo interroga. Questo è il classico problema di lasciare che una libreria C richiami dentro il Pascal: il callback deve essere una funzione semplice con convenzione di chiamata C, non un metodo, perché PDFium memorizza e invoca un nudo puntatore a funzione che non sa nulla di oggetti Pascal o di Self

Diagramma del ponte IFSDK_PAUSE che permette a un token di annullamento Delphi di rispondere al callback NeedToPauseNow di PDFium attraverso il puntatore user lasciato intatto
Il record contiene sia il nudo puntatore user che PDFium legge sia un riferimento di interfaccia contato che tiene vivo il token, così un ritorno diverso da zero permette al ciclo di chiudere il rendering e fermarsi per sempre
type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium legge questo; .user tiene il token
    Token: IPdfCancellationToken; // il rif forte tiene vivo il token
  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; // diverso da zero: PDFium ferma questo blocco
end;

Il callback recupera il token facendo il cast di pThis^.user di nuovo al tipo interfaccia e legge IsCancelled. Nulla al suo interno alloca, prende lock o blocca, il che conta perché PDFium lo chiama sul thread di rendering dopo ogni blocco e qualsiasi lavoro fatto qui si somma al costo del rendering stesso. La guardia contro una struttura nil o un campo user nil significa che la stessa funzione si può installare senza rischi anche su un rendering a cui non è mai stato dato un token vero

Tenere vivo il token per tutto il ciclo

Fare il cast di un puntatore a interfaccia passando per un Pointer grezzo e ritorno è il luogo in cui nascono i bug di durata di vita. Un IInterface in Delphi è a conteggio di riferimenti, e il conteggio si muove solo quando il compilatore può vedere una variabile di tipo interfaccia che viene assegnata. Memorizzare il token soltanto come nudo puntatore dentro IFSDK_PAUSE.user lo nasconderebbe del tutto al contatore di riferimenti. Se l'unico altro riferimento a quel token uscisse dallo scope mentre il ciclo di Continue è ancora in corso, l'oggetto verrebbe liberato sotto il callback, e il blocco successivo dereferenzierebbe un puntatore penzolante

Ecco perché il descrittore è un record che contiene due cose, non una. Il campo Pause è la struttura che PDFium legge. Il campo Token è un vero riferimento di tipo interfaccia che il compilatore conta, ed esiste per nessun altro motivo se non fissare il token in memoria finché il record vive. Il record è una variabile locale sullo stack della routine di rendering, quindi resta valido per tutta la durata del ciclo e viene smontato solo quando la routine esce. Il nudo puntatore in user e il riferimento contato in Token nominano lo stesso oggetto; uno è ciò che PDFium può leggere, l'altro è ciò che impedisce a quell'oggetto di essere raccolto

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... scegliete EffectiveToken ...

  // Prima il rif forte, poi pubblicate lo stesso oggetto a PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

Chiudere il contesto di rendering comunque finisca il ciclo

Ogni chiamata a FPDF_RenderPageBitmap_Start alloca uno stato progressivo che PDFium associa alla pagina, e quello stato viene rilasciato solo da FPDF_RenderPage_Close. Ci sono tre vie di uscita dal ciclo di guida. La pagina finisce e l'ultimo stato è FPDF_RENDER_DONE. Il token scatta e il ciclo esce in anticipo segnalando l'annullamento. Qualcosa fallisce e lo stato è FPDF_RENDER_FAILED. Tutte e tre devono chiamare Close, e il percorso di annullamento è il più facile da sbagliare, perché la forma naturale del "vedo l'annullamento, esco" tende a saltare la pulizia mentre corre verso l'uscita. Lasciare Close irraggiungibile fa perdere lo stato di quella pagina, e un viewer che permette all'utente di annullare un rendering dopo l'altro accumulerebbe quella perdita su ogni pagina interrotta

La forma robusta mette il ciclo e la classificazione del risultato dentro un try e FPDF_RenderPage_Close nel finally corrispondente. La bitmap di destinazione viene distrutta nello stesso blocco. L'annullamento può lasciare il ciclo attraverso un Exit anticipato e il finally gira comunque, quindi esiste esattamente un punto che libera lo stato progressivo e non lo si può aggirare

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
  // Libera lo stato progressivo allocato da Start; obbligatorio ovunque.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

Il ciclo controlla il token prima di ogni Continue oltre ad affidarsi al callback che sta dentro. Il callback accorcia il blocco corrente; il controllo del ciclo impedisce al successivo di partire. Insieme limitano il tempo che un annullamento impiega a fare effetto a grosso modo la durata di un blocco

Tre esiti, e cosa contiene la bitmap dopo un annullamento

Il punto di ingresso pubblico è TPdf.RenderPageProgressive, e restituisce un TPdfProgressiveStatus che vale prsDone, prsCancelled oppure prsFailed. I valori rispecchiano le costanti FPDF_RENDER_* di PDFium in idioma Pascal, ma incorporano il caso di annullamento come risultato di prima classe anziché come errore

Il punto su cui le persone inciampano è cosa contenga la bitmap di destinazione dopo prsCancelled. Non è vuota. PDFium disegna progressivamente nella stessa bitmap blocco dopo blocco, quindi quando un annullamento ferma il ciclo, la bitmap contiene quanto è stato dipinto fino a quel momento, cioè una immagine parziale: alcune bande finite, il resto che mostra ancora il colore di riempimento. Se quel risultato parziale sia utile dipende dal chiamante. Un viewer che sta per buttare via la bitmap perché l'utente è andato altrove può semplicemente ignorarla. Un viewer che vuole mostrare un'anteprima a basso costo può tenerla. Ciò che non dovete fare è dare per scontato che prsCancelled implichi una bitmap vuota o indefinita; implica un'istantanea veritiera di un rendering incompiuto

Diagramma degli esiti prsDone, prsCancelled e prsFailed di un rendering PDF progressivo in Delphi e della bitmap parziale che un annullamento lascia nella destinazione
Un rendering annullato lascia una istantanea parziale veritiera con alcune bande dipinte e il resto che mostra ancora il colore di riempimento, mentre un solo blocco finally chiude lo stato su ogni percorso
var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Il token parte non annullato; portate Token.IsCancelled a vero da
    // altrove (una azione UI, un evento di navigazione) per interrompere.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // reso completamente
      prsCancelled: ;                            // bitmap parziale, di solito scartata
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

Il token nil e un percorso di callback senza rami

L'annullamento è a scelta. Un chiamante che vuole soltanto il rendering progressivo per il vantaggio di poter pompare i messaggi, senza alcuna intenzione di interrompere, dovrebbe poter passare nil come token. Il modo ingenuo di supportarlo è disseminare controlli del tipo "se è stato fornito un token" dentro il callback e il ciclo, il che significa un ramo su ogni blocco e un callback che deve gestire sia un token vero sia la sua assenza

L'implementazione lo evita sostituendo un singleton quando il chiamante non passa nulla. Un token nil viene scambiato con PdfNoCancellationToken, una interfaccia il cui IsCancelled è sempre falso. Da quel momento il callback e il ciclo hanno un token da interrogare in ogni caso, quindi nessuno dei due ha bisogno di un controllo su nil né di un percorso speciale. Il token che non annulla mai risponde semplicemente sempre falso, il callback restituisce sempre zero, e il rendering arriva a compimento esattamente come farebbe uno non annullabile. Il comportamento facoltativo è modellato come un token che non scatta mai anziché come assenza di token, il che mantiene uniforme il percorso caldo

// nil -> singleton che non annulla mai, così il percorso di callback resta
// identico sia che il chiamante abbia scelto la cancellazione sia che no.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

La forma che ne emerge è piccola e vale la pena ribadirla, perché è la parte riutilizzabile. Una libreria C che supporta un callback vi dà esattamente un canale per passare stato dentro quel callback, il puntatore utente opaco. Mettete un riferimento di interfaccia Pascal contato dietro quel puntatore, tenete vivo un secondo riferimento vero accanto alla struttura così che l'oggetto non possa essere raccolto a metà chiamata, e rileggete l'interfaccia dentro una funzione statica cdecl. Avvolgete l'intero ciclo di guida in un try e liberate il contesto nativo nel finally. Lo stesso modello si trasferisce a qualsiasi operazione PDFium progressiva o guidata da callback in cui il codice Pascal deve restare padrone della durata di vita mentre il C tiene un puntatore

L'annullamento è solo una metà di un viewer reattivo. L'altra metà è non ridisegnare pagine che avete già disegnato, e mantenere fluidi zoom e scorrimento servendo bitmap dalla cache, argomento trattato nel nostro articolo sulla cache di rendering e le prestazioni dello zoom. Per come il rendering annullabile si inserisce in un viewer completo accanto a navigazione, selezione e ricerca, vedete costruire un viewer PDF ricco di funzioni con PDFium Component. Il rendering progressivo descritto qui è distribuito come parte di PDFium Component per Delphi e Lazarus, insieme alle API di caricamento, rendering e moduli trattate altrove in questo blog