Articolo tecnico

Bug di Doppia Rotazione e Fit-Zoom in PDFium su Delphi

La funzione FPDF_RenderPageBitmap del componente PDFium accetta un argomento rotate che PDFium aggiunge sempre sopra qualunque rotazione la pagina già porti nella propria voce /Rotate, quindi leggere la rotazione memorizzata di una pagina e ripassare quello stesso valore alla chiamata di rendering ruota la pagina due volte. Lo stesso identico errore compare nella matematica del fit-zoom: dimensionare una miniatura a partire dalla larghezza e altezza non ruotate della pagina produce il rapporto d'aspetto sbagliato ogni volta che /Rotate è 90 o 270 gradi, perché il bitmap renderizzato risulta con larghezza e altezza scambiate

Il fallimento è facile da individuare una volta che sai cosa cercare, e facile da perdere prima di allora. Un lotto di fatture scansionate arriva con un mix di originali verticali e orizzontali, qualcuno ne raddrizza metà con una rotazione di 90 gradi in Acrobat prima di archiviarle, e la striscia di miniature in un visualizzatore Delphi costruito su PDFium renderizza quelle particolari pagine di lato, capovolte, o compresse in un riquadro pensato per l'orientamento sbagliato. Nulla solleva un'eccezione. Nulla registra un errore. I pixel sono semplicemente sbagliati, e solo per il sottoinsieme di pagine che qualcuno ha ruotato successivamente — esattamente il tipo di bug che sopravvive a un passaggio di QA completo contro un PDF di test non ruotato e poi emerge in produzione a pagina 47 di uno vero

Perché PDFium ruota la pagina due volte?

PDFium applica automaticamente il valore /Rotate proprio di una pagina ogni volta che renderizza un bitmap, indipendentemente da cosa venga passato al renderer. Il parametro rotate di FPDF_RenderPageBitmap, esposto in PDFiumPas come i valori TRotation ro0, ro90, ro180 e ro270 su TPdf.RenderPage, TPdf.RenderTile e TPdf.RenderPageThumbnail, non imposta l'angolo finale a cui una pagina dovrebbe risultare; il parametro rotate imposta quanta rotazione extra stratificare sopra a qualunque cosa il dizionario di pagina già specifichi, motivo per cui ognuno di quei metodi lo ha come default a ro0

TPdf.PageRotation legge quello stesso valore /Rotate tramite FPDFPage_GetRotation, e il codice applicativo spesso ne ha bisogno per ragioni che non hanno nulla a che fare con il rendering, come decidere come disporre un'annotazione nello spazio pagina. La trappola è un'unica riga: passare PageRotation nell'argomento Rotation di RenderPage, aspettandosi che la chiamata normalizzi la pagina a verticale. Una pagina già salvata con /Rotate 90 viene visualizzata correttamente, ruotata, in qualsiasi visualizzatore conforme, PDFium incluso; aggiungi di nuovo ro90 sopra a ciò e la pagina gira a 180 gradi invece dei 90 previsti, mentre una pagina senza alcuna rotazione riceve un quarto di giro indesiderato senza motivo

// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);

// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);

A cosa serve realmente il parametro Rotation

Il parametro Rotation guadagna il proprio posto nell'API per un compito genuinamente diverso: aggiungere una rotazione solo di visualizzazione che non ha nulla a che fare con l'orientamento memorizzato di una pagina, il tipo che un pulsante toolbar ruota-vista applica senza toccare il file sottostante. TPdfView mantiene i due concetti come due proprietà separate esattamente per questo motivo. TPdfView.PageRotation rispecchia il /Rotate proprio della pagina e, tramite FPDFPage_SetRotation, può riscrivere un nuovo valore nel documento; TPdfView.Rotation è una proprietà transitoria, solo di visualizzazione, che ha come default ro0 e non tocca mai il file. Leggere la prima proprietà e scriverla nella seconda è l'intero bug in una frase

// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
  case PdfView.Rotation of
    ro0:   PdfView.Rotation := ro90;
    ro90:  PdfView.Rotation := ro180;
    ro180: PdfView.Rotation := ro270;
    ro270: PdfView.Rotation := ro0;
  end;
end;

// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
  case PdfView.PageRotation of
    ro0:   PdfView.PageRotation := ro90;
    ro90:  PdfView.PageRotation := ro180;
    ro180: PdfView.PageRotation := ro270;
    ro270: PdfView.PageRotation := ro0;
  end;
end;

Perché il dimensionamento fit-zoom si rompe nello stesso modo?

Il dimensionamento fit-zoom si rompe per un motivo speculare: il calcolo parte dalla coppia di numeri sbagliata invece che dall'angolo sbagliato. Un modo tipico di dimensionare un riquadro di miniatura chiede a PDFium larghezza e altezza di una pagina, confronta quel rapporto d'aspetto con il riquadro disponibile, e calcola il rettangolo più grande che vi si adatta — il che funziona pulitamente per una pagina non ruotata. Lo stesso calcolo fallisce silenziosamente per una pagina /Rotate 90 o /Rotate 270 quando la larghezza e l'altezza provengono da una chiamata che riporta la dimensione intrinseca, non ruotata, della pagina: una pagina A4 verticale che porta /Rotate 90 riporta comunque circa 595 per 842 punti, anche se PDFium la renderizza, correttamente, a circa 842 per 595 una volta che la rotazione ha effetto, e un riquadro di adattamento calcolato dalla coppia non ruotata finisce interamente sagomato per l'orientamento sbagliato

FPDF_GetPageSizeByIndex è un esempio concreto di una chiamata che riporta per progetto quella dimensione intrinseca, non ruotata, il che la rende comoda per scansionare le dimensioni delle pagine senza caricare ogni pagina e rischiosa per la matematica del fit-zoom che dimentica di tenerne conto. La correzione segue direttamente dal nominare il problema: verifica la rotazione della pagina prima di eseguire l'aritmetica di adattamento, scambia larghezza e altezza ogni volta che quella rotazione è di 90 o 270 gradi, calcola il riquadro di adattamento dalla coppia scambiata, e passa comunque ro0 alla vera chiamata di rendering, perché è sempre PDFium ad applicare la vera rotazione

Ottenere miniature corrette senza reinventare la matematica di adattamento

TPdf.RenderPageThumbnail porta già questa correzione, quindi il percorso più breve verso una miniatura corretta è chiamarlo invece di riassemblare a mano la logica di adattamento-e-rotazione. Dato un indice di pagina a base 1 e una larghezza e altezza massime, RenderPageThumbnail calcola un riquadro di adattamento, lo corregge internamente per un /Rotate di 90 o 270, e restituisce un bitmap posseduto dal chiamante senza disturbare la pagina corrente del documento o generare un evento OnPageChange — il che conta per una striscia di miniature costruita accanto a un visualizzatore live sulla stessa istanza TPdf

// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
  MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
  PgW, PgH, Swap: Integer;
begin
  PgW := Round(PageW);
  PgH := Round(PageH);
  if PgW < 1 then PgW := 1;
  if PgH < 1 then PgH := 1;

  if Rotation in [ro90, ro270] then
  begin
    Swap := PgW;
    PgW := PgH;
    PgH := Swap;
  end;

  Result := (MaxW > 0) and (MaxH > 0);
  if not Result then
    Exit;

  if PgW * MaxH > PgH * MaxW then
  begin
    FitW := MaxW;
    FitH := (MaxW * PgH) div PgW;
  end
  else
  begin
    FitH := MaxH;
    FitW := (MaxH * PgW) div PgH;
  end;
end;

L'helper FitBox vale comunque la pena tenerlo a portata di mano, perché RenderPageThumbnail copre solo il caso a bitmap singolo. Una griglia di miniature personalizzata, una striscia di anteprima di stampa, o una finestra di selezione pagina che dispone diverse pagine contro riquadri indipendenti ha bisogno della stessa matematica di adattamento consapevole della rotazione senza necessariamente volere un bitmap fresco per ogni riquadro, e le stesse modalità di zoom adatta-pagina e adatta-larghezza di TPdfView si appoggiano internamente sulla stessa idea, scegliendo tra larghezza e altezza di una pagina per il calcolo del rapporto di zoom in base alla rotazione corrente della vista prima di confrontarlo con l'area client disponibile. Se le prestazioni di zoom e scorrimento in quel tipo di visualizzatore sono il prossimo problema in elenco, l'articolo di approfondimento sulla cache di rendering e zoom fluido in un visualizzatore Delphi basato su PDFium riprende esattamente da dove lascia il dimensionamento corretto

Individuare una doppia rotazione prima che lo faccia un cliente

Una doppia rotazione ha una firma visiva affidabile: una pagina che è stata ruotata di 90 gradi in ingresso esce sembrando ruotata di 180 rispetto al resto del documento, non di 90, perché il ro90 extra si è impilato sopra il ro90 proprio della pagina invece di sostituirlo. Una fixture di test costruita solo da pagine /Rotate 0 non catturerà mai questo, poiché aggiungere ro0 a ro0 resta ro0 e il bug rimane invisibile; una fixture ha bisogno di almeno una pagina salvata con /Rotate 90 e una con /Rotate 270 prima che un percorso di codice per miniature o fit-zoom possa essere considerato affidabile

La pipeline base da pagina a bitmap trattata in renderizzare pagine PDF in JPEG con il componente PDFium già renderizza correttamente le pagine ruotate senza alcun codice per casi speciali, proprio perché lascia Rotation al proprio default ro0 e lascia che sia PDFium ad applicare /Rotate autonomamente. Il bug della doppia rotazione compare solo quando il codice applicativo inizia a rileggere PageRotation e a passarlo da qualche parte a cui non appartiene

Le chiamate di rendering consapevoli della rotazione e il dimensionamento delle miniature descritti qui fanno parte del componente PDFium per Delphi e C++Builder, insieme al resto delle API di rendering, visualizzazione ed estrazione testo costruite sulle stesse classi TPdf e TPdfView