Articolo tecnico

Visualizzatore PDF a scorrimento continuo in Delphi con PDFium Component

Una singola pagina A4 renderizzata a uno zoom di lettura confortevole equivale a una bitmap a 32 bit di pochi megabyte. Moltiplicando questo dato per un contratto di 400 pagine, i calcoli cessano di essere astratti: eseguire il rendering preliminare di ogni pagina significa richiedere a Windows oltre un gigabyte di bitmap che l'utente visualizzerà solo una schermata alla volta. L'applicazione rischia di esaurire lo spazio degli indirizzi in una build a 32 bit, o trascorre i primi secondi bloccata mentre la GPU e il parser elaborano pagine a cui l'utente non è ancora arrivato. Un lettore a scorrimento continuo deve dare la sensazione di un unico lungo nastro di pagine, senza tuttavia caricarle tutte in memoria simultaneamente

Questa gestione è l'elemento cruciale dell'intera struttura. Il Componente PDFium risolve la questione all'interno di TPdfView, per cui la maggior parte dell'attività consiste nello scegliere la modalità di visualizzazione corretta e nel comprendere il comportamento del componente. Le attività non gestite automaticamente, come il dimensionamento delle pagine per il flusso di lettura e il mantenimento della reattività durante lo scorrimento veloce, richiedono l'inserimento di una piccola quantità di codice. Se stai ancora configurando gli elementi di contorno (barra degli strumenti, miniature, casella di ricerca), l'articolo sul visualizzatore completo tratta questi argomenti; in questa sede analizzeremo unicamente lo scorrimento

Il layout è una modalità di visualizzazione, non un pannello di bitmap

L'istinto iniziale nello sviluppo VCL potrebbe spingere a utilizzare un box di scorrimento e a inserirvi controlli immagine, uno per pagina. È consigliabile evitare questo approccio, che costringerebbe a gestire contemporaneamente il posizionamento delle pagine, i calcoli di scorrimento e l'allocazione della memoria, finendo per riscrivere in modo inefficiente logiche già pronte. TPdfView modella già il documento come una sequenza continua di pagine ed espone il layout tramite la proprietà DisplayMode

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

Questa è l'intera configurazione per lo scorrimento continuo. dmSingleContinuous dispone le pagine in un'unica colonna verticale gestendo internamente gli spazi tra di esse, e la visualizzazione scorre lungo tale colonna come su un'unica superficie. Non è necessario configurare controlli per singola pagina o scrivere gestori di scorrimento per la navigazione standard. Si noti il controllo su Pdf.Active dopo l'assegnazione: l'apertura di un documento non solleva eccezioni, per cui un file danneggiato o protetto da password lascia Active impostato su False senza errori rilevabili, e un visualizzatore che ometta questo controllo mostrerà una schermata vuota senza segnalazioni

La stessa proprietà gestisce le modalità a doppia pagina. dmTwoPageContinuous affianca le pagine, due per riga, per la lettura in stile libro richiesta da alcuni documenti; dmTwoPageContinuousWithCover esegue la stessa disposizione ma lascia la prima pagina da sola come copertina, in modo che le successive coppie seguano la suddivisione pari-dispari. Tutte e tre le modalità supportano lo scorrimento continuo. Il passaggio dall'una all'altra richiede una singola assegnazione, rendendo immediata l'integrazione di una casella combinata per la selezione della visualizzazione

Vengono rasterizzate solo le pagine visibili

Il motivo per cui questo approccio funziona con un file di 400 pagine è che la colonna è virtuale. TPdfView conosce l'altezza di ogni pagina dall'albero delle pagine del documento, potendo calcolare l'estensione dello scorrimento e la posizione di ciascuna pagina senza eseguire rendering preventivi. La rasterizzazione, il passaggio oneroso che converte il flusso di contenuti della pagina in pixel, avviene solo per le pagine che attualmente intersecano l'area visibile (viewport), con un piccolo margine per anticipare l'ingresso della pagina successiva. Durante lo scorrimento, le pagine che entrano nell'area visibile vengono renderizzate e quelle che escono liberano le relative bitmap. L'uso della memoria rimane proporzionale a ciò che è visibile a schermo, non alla lunghezza del documento

Questo principio è importante perché ridefinisce la valutazione delle prestazioni. Aprire un documento di 400 pagine è economico poiché analizza la struttura, not il contenuto. Il carico di elaborazione è legato alla singola pagina e viene applicato in modalità differita (lazy), nel momento in cui la pagina si avvicina all'area visibile. Un visualizzatore che appare immediato all'avvio e fluido nello scorrimento non esegue meno lavoro complessivo, ma distribuisce l'elaborazione lungo il percorso di lettura effettivo dell'utente, eliminando ciò che non è più necessario. Di conseguenza, non è quasi mai consigliabile forzare il rendering preventivo delle pagine. Lascia che sia la visualizzazione a determinare ciò che deve essere visibile

Adattare le pagine alla larghezza, lasciando lo zoom automatico

Una colonna di lettura richiede che le pagine siano dimensionate in base alla larghezza del pannello, anziché bloccate a uno zoom assoluto. FitMode gestisce questo comportamento e lo mantiene anche al ridimensionamento della finestra

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

Con pfmFitWidth il componente ricalcola lo zoom ad ogni variazione delle dimensioni della visualizzazione, facendo sì che la colonna occupi sempre la larghezza disponibile e che l'altezza delle pagine si adegui di conseguenza. C'è un'insidia comune: l'assegnazione diretta di Zoom reimposta FitMode su pfmNone. Questo comportamento è intenzionale, poiché uno zoom manuale e un adattamento automatico sono impostazioni contrastanti, ma implica che un'istruzione isolata come PdfView.Zoom := 1.0 disabiliti l'adattamento alla larghezza, interrompendo il reflow al ridimensionamento successivo. Se offri sia un controllo dello zoom che un pulsante di adattamento, gestiscili come un cambio di modalità: l'attivazione dell'uno esclude l'altro, e spetta al codice determinare quale prevalga

Per i controlli dello zoom assoluto, la visualizzazione espone i valori di adattamento applicabili o visualizzabili: PageWidthZoom[PageNumber] restituisce lo zoom che adatterebbe la pagina alla larghezza, mentre PageZoom adatta l'intera pagina. La lettura di questi parametri consente di popolare un menu "Adatta alla larghezza" / "Adatta alla pagina" senza dover inserire percentuali fisse che risulterebbero errate su pagine con orientamento orizzontale o fuori formato

Mantenere la reattività dello scorrimento veloce con il rendering progressivo

Il percorso di rendering predefinito disegna una pagina fino al suo completamento prima di restituire il controllo. Questo va bene per una singola pagina. Durante uno scorrimento veloce in un documento denso di contenuti, tuttavia, il comportamento è problematico: ogni pagina visualizzata rapidamente avvia una rasterizzazione completa e, se lo scorrimento dell'utente è più veloce del tempo di rendering delle pagine, le richieste si accumulano causando rallentamenti poiché l'applicazione elabora pagine già superate. La soluzione consiste nel rendere il rendering annullabile, interrompendolo nel momento in cui l'utente prosegue nello scorrimento

RenderPageProgressive esegue il rendering in blocchi e verifica un token di annullamento al confine di ciascun blocco, consentendo di interrompere l'elaborazione di una pagina non appena questa esce dall'area visibile, senza dover attendere il completamento della rasterizzazione

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

L'elemento chiave è il valore restituito. prsDone indica che la bitmap è completamente disegnata e pronta per la visualizzazione a schermo; prsCancelled indica che una nuova posizione di scorrimento ha superato la pagina corrente, per cui il risultato parziale viene scartato anziché mostrato; prsFailed segnala un errore effettivo su quella pagina. L'annullamento viene verificato ai confini dei blocchi di elaborazione e non in modo preventivo, per cui è da attendersi una latenza di poche decine di millisecondi tra la chiamata a Cancel e l'effettivo arresto del rendering. Questo approccio è comunque molto più efficiente del consentire a un rendering ormai superato di bloccare la coda di esecuzione. Il passaggio di nil come token esegue il rendering direttamente fino al completamento, scelta corretta per rendering singoli come l'anteprima di stampa in cui non vi sono azioni da annullare

Quando si richiama la funzione RenderPage che restituisce una nuova TBitmap, ricorda che il chiamante ne assume la proprietà e deve effettuarne la liberazione tramite Free. In un ciclo di scorrimento che alloca una bitmap per pagina, omettere questa operazione genera una perdita di memoria (leak) che cresce con ogni pagina scorta, ricreando la saturazione della memoria che la struttura a scorrimento continuo intendeva evitare. Si consiglia di eseguire il rendering in una bitmap riutilizzata ove possibile

Cosa rimane da implementare

Il lettore a scorrimento continuo è gestito quasi interamente dal componente. Scegli dmSingleContinuous per il layout, imposti pfmFitWidth in modo che la colonna si adatti alla finestra, e verifichi Pdf.Active per gestire correttamente i file danneggiati. L'unico elemento che conviene implementare autonomamente è il rendering annullabile, poiché la qualità di un lettore si valuta da come risponde quando un utente trascina rapidamente la barra di scorrimento fino in fondo a un lungo documento e il pannello deve adeguarsi tempestivamente. Qualsiasi altra funzionalità, come la selezione del testo tra le pagine, l'evidenziazione delle ricerche o l'albero dei segnalibri, rappresenta un'attività di interfaccia che si colloca sopra questa superficie di scorrimento anziché al suo interno

Le API TPdfView, DisplayMode e RenderPageProgressive mostrate qui fanno parte del Componente PDFium per Delphi e Lazarus