Technical Article

Rendering PDF progressivo annullabile in Delphi (PDFium)

La maggior parte delle pagine PDF si rasterizza in pochi millisecondi e non ci pensi mai. Poi un utente apre un disegno ingegneristico A1, una pagina zeppa di decine di migliaia di tratti vettoriali o un poster affollato di gruppi di trasparenza e maschere sfumate (soft masks), e la singola chiamata che lo disegna impiega due o tre secondi. Se tale chiamata viene eseguita 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 richiede davvero così tanto tempo. Il difetto è che il rendering è una singola chiamata bloccante indivisibile senza alcun modo per riprendere fiato e senza alcun modo per fermarsi

Questo articolo riguarda esattamente uno di quei due problemi: annullare un rendering lungo di una singola pagina senza bloccare la UI. L'utente ha cliccato sulla pagina successiva, ha ingrandito o ha chiuso il documento e il rendering in volo è ora un lavoro sprecato che dovrebbe terminare alla prima occasione utile invece di arrivare fino in fondo. Fluidificare lo scorrimento e lo zoom mettendo in cache ciò che era già stato rasterizzato è un problema separato con un suo design, trattato nell'articolo di accompagnamento linkato alla fine. Qui l'unica questione è come fare in modo che un rendering progressivo risponda a una richiesta di annullamento in modo rapido e pulito

L'API di rendering progressivo già inclusa in PDFium

PDFium ha previsto la metà del problema relativa al blocco (freezing). Oltre a FPDF_RenderPageBitmap one-shot, espone una variante progressiva che divide una pagina in blocchi di lavoro (chunks). Si chiama FPDF_RenderPageBitmap_Start una volta per impostare il rendering su una bitmap di destinazione, quindi si chiama ripetutamente FPDF_RenderPage_Continue. Ogni Continue rasterizza una porzione limitata e restituisce uno stato. FPDF_RENDER_TOBECONTINUED significa che c'è ancora da fare, FPDF_RENDER_DONE significa che la pagina è finita e FPDF_RENDER_FAILED significa che si è fermata per un errore. Quando il ciclo termina, si chiama FPDF_RenderPage_Close per rilasciare lo stato progressivo della pagina. Poiché il controllo ritorna al tuo codice tra un blocco e l'altro, puoi smaltire i messaggi, aggiornare un indicatore di avanzamento o verificare se il lavoro è ancora desiderato

Il meccanismo fornito da PDFium per decidere quando cedere il controllo è una struct di callback denominata IFSDK_PAUSE. La si passa 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, l'attuale Continue si ferma in anticipo e restituisce il controllo con FPDF_RENDER_TOBECONTINUED. La struct contiene anche un campo version, che deve essere impostato a 1, e un puntatore a forma libera user che PDFium non tocca mai e passa inalterato. Quel puntatore intatto è l'intero fulcro del design che segue

Riconvertire pause in cancel

L'intento originale di NeedToPauseNow è il time-slicing. Restituire un valore diverso da zero quando il budget dei fotogrammi è esaurito, restituire zero per continuare a renderizzare, e PDFium fa una pausa in modo da poter fare qualcos'altro prima di riprendere lo stesso rendering. Il PDFium Component riutilizza quello stesso segnale per un verbo diverso. Invece di rispondere "dovrei fermarmi e lasciarti riprendere", il callback risponde "questo lavoro è stato annullato?". Le due cose si sovrappongono in modo pulito a causa di ciò che fa il ciclo quando vede il flag. Una pausa genuina si aspetta un Continue successivo; 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, perciò lo stesso ritorno non nullo che PDFium legge come "ferma questo blocco" diventa, in effetti, "fermati per sempre"

L'annullamento è espresso tramite un'interfaccia, IPdfCancellationToken, la cui proprietà IsCancelled passa da falso a vero quando qualche altra parte del programma chiede di interrompere il rendering. Il ponte tra quell'interfaccia Pascal e il callback C di PDFium è un singolo puntatore. Il riferimento all'interfaccia del token viene scritto in IFSDK_PAUSE.user e un callback statico cdecl lo rilegge e lo interroga. Questo è il classico problema di permettere a una libreria C di richiamare in Pascal: il callback deve essere una semplice funzione con convenzione di chiamata C, non un metodo, perché PDFium memorizza e invoca un semplice puntatore a funzione che non sa nulla degli oggetti Pascal o di 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;

Il callback recupera il token eseguendo il cast di pThis^.user di nuovo al tipo interfaccia e legge IsCancelled. Niente in esso alloca, blocca (locks) o blocca in attesa (blocks), il che è importante perché PDFium lo chiama sul thread di rendering dopo ogni blocco e qualsiasi lavoro svolto qui si aggiunge al costo del rendering stesso. La protezione contro una struct nil o un campo user nil significa che la stessa funzione è sicura da installare anche su un rendering a cui non è mai stato fornito un vero token

Mantenere in vita il token per tutto il ciclo

Il cast di un puntatore di interfaccia tramite un Pointer grezzo e ritorno è il luogo in cui nascono i bug di ciclo di vita. Una IInterface in Delphi è basata sul conteggio dei riferimenti, e il conteggio si muove solo quando il compilatore vede l'assegnazione di una variabile di tipo interfaccia. Memorizzare il token unicamente come un puntatore nudo all'interno di IFSDK_PAUSE.user lo nasconderebbe completamente al contatore di riferimenti. Se l'unico altro riferimento a quel token uscisse dallo scope mentre il ciclo Continue fosse ancora in esecuzione, l'oggetto verrebbe liberato da sotto il callback, e il blocco successivo dereferenzierebbe un puntatore orfano (dangling)

Ecco perché il descrittore è un record che contiene due cose, non una. Il campo Pause è la struct che PDFium legge. Il campo Token è un vero riferimento di tipo interfaccia che il compilatore conta, ed esiste senza altro motivo se non quello di ancorare il token in memoria per tutta la durata del record. Il record è una variabile locale nello stack della routine di rendering, perciò rimane valido per l'intera durata del ciclo e viene smontato solo all'uscita della routine. Il puntatore nudo 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 (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);

Chiudere il contesto di rendering indipendentemente da come finisce 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 modi per uscire dal ciclo principale. 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. Tutti e tre devono chiamare Close, e il percorso di annullamento è il più facile da sbagliare, perché la forma naturale di "vedi annullamento, interrompi" tende a saltare la pulizia mentre si avvia verso l'uscita. Lasciare Close irraggiungibile disperde lo stato per pagina (leaks), e un visualizzatore che consente all'utente di annullare rendering dopo rendering accumulerebbe tale perdita a ogni pagina interrotta

La forma robusta inserisce il ciclo e la classificazione del risultato all'interno di un try e FPDF_RenderPage_Close nel corrispondente finally. La bitmap di destinazione viene distrutta nello stesso blocco. L'annullamento può uscire dal ciclo tramite un Exit anticipato e il blocco finally viene comunque eseguito, quindi esiste un solo punto in cui viene liberato lo stato progressivo e non può essere aggirato

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;

Il ciclo controlla il token prima di ogni Continue oltre a fare affidamento sul callback al suo interno. Il callback abbrevia il blocco corrente; il controllo del ciclo impedisce l'avvio di quello successivo. Insieme limitano il tempo necessario affinché un annullamento abbia effetto a circa 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 può essere prsDone, prsCancelled o prsFailed. I valori rispecchiano le costanti FPDF_RENDER_* di PDFium nell'idioma Pascal, ma includono il caso di annullamento come un risultato di prima classe piuttosto che un errore

Il punto che sorprende le persone è cosa contiene la bitmap di destinazione dopo prsCancelled. Non è vuota. PDFium renderizza progressivamente nella stessa bitmap blocco dopo blocco, quindi quando un annullamento ferma il ciclo, la bitmap contiene tutto ciò che è stato dipinto fino a quel momento, ovvero un'immagine parziale: alcune fasce completate, il resto mostra ancora il colore di riempimento. Se quel risultato parziale è utile dipende dal chiamante. Un visualizzatore che sta per scartare la bitmap perché l'utente ha navigato altrove può semplicemente ignorarla. Un visualizzatore che vuole mostrare un'anteprima a basso costo può conservarla. Quello che non devi fare è presumere che prsCancelled implichi una bitmap vuota o indefinita; implica un'istantanea veritiera di un rendering non finito

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;

Il token nil e un percorso di callback senza diramazioni

L'annullamento è facoltativo (opt-in). Un chiamante che desidera solo un rendering progressivo per i vantaggi dello smaltimento dei messaggi, senza alcuna intenzione di interromperlo, dovrebbe poter passare nil per il token. Il modo ingenuo per supportarlo è spargere controlli "se è stato fornito un token" attraverso il callback e il ciclo, il che significa un ramo su ogni blocco e un callback che deve gestire sia un token reale che la sua assenza

L'implementazione evita tutto ciò sostituendo un singleton quando il chiamante non passa nulla. Un token nil viene scambiato con PdfNoCancellationToken, un'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 nil e nessuno dei due ha bisogno di un percorso speciale. Il token mai-annullare semplicemente risponde sempre falso, il callback restituisce sempre zero, e il rendering prosegue fino al completamento esattamente come farebbe uno non annullabile. Il comportamento opzionale è modellato come un token che non scatta mai, invece che come l'assenza di un token, il che mantiene uniforme il percorso critico (hot path)

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

La forma che emerge è piccola e vale la pena ribadirla, perché è la parte riutilizzabile. Una libreria C che supporta un callback fornisce esattamente un canale per passare lo stato a quel callback, il puntatore opaco user. Metti un riferimento di interfaccia Pascal contato dietro a quel puntatore, mantieni in vita un secondo riferimento reale accanto alla struct in modo che l'oggetto non possa essere raccolto (collected) a metà chiamata, e rileggi l'interfaccia all'interno di una funzione statica cdecl. Avvolgi l'intero ciclo di guida in un try e libera il contesto nativo nel blocco finally. Lo stesso modello si estende a qualsiasi operazione PDFium progressiva o guidata da callback in cui il codice Pascal debba mantenere il controllo della durata mentre il C detiene un puntatore

L'annullamento è solo una metà di un visualizzatore reattivo. L'altra metà consiste nel non re-renderizzare le pagine che hai già disegnato e nel mantenere fluidi zoom e scorrimento fornendo bitmap nella cache, argomento trattato nel nostro articolo sulla cache di rendering e le prestazioni dello zoom. Per capire come il rendering annullabile si inserisca in un visualizzatore completo insieme a navigazione, selezione e ricerca, vedi l'articolo creare un visualizzatore PDF ricco di funzionalità con il componente PDFium VCL. Il rendering progressivo qui descritto viene fornito come parte del PDFium Component per Delphi e Lazarus, insieme alle API di caricamento, rendering e form trattate altrove su questo blog