Article technique

Bogues de double rotation et de zoom d'ajustement PDFium en Delphi

La fonction FPDF_RenderPageBitmap du composant PDFium accepte un argument rotate que PDFium ajoute toujours par-dessus quelle que soit la rotation que la page porte déjà dans sa propre entrée /Rotate, si bien que lire la rotation stockée d'une page et réinjecter cette même valeur dans l'appel de rendu fait pivoter la page deux fois. La même erreur exacte apparaît dans les calculs de zoom d'ajustement : dimensionner une vignette à partir de la largeur et de la hauteur non pivotées de la page produit le mauvais rapport d'aspect chaque fois que /Rotate vaut 90 ou 270 degrés, car le bitmap rendu ressort avec largeur et hauteur échangées

L'échec est facile à repérer une fois qu'on sait quoi chercher, et facile à manquer jusque-là. Un lot de factures scannées arrive avec un mélange d'originaux portrait et paysage, quelqu'un en redresse la moitié avec une rotation de 90 degrés dans Acrobat avant archivage, et le bandeau de vignettes d'une visionneuse Delphi construite sur PDFium rend ces pages particulières de travers, à l'envers, ou comprimées dans une boîte à la forme de la mauvaise orientation. Rien ne lève d'exception. Rien ne journalise d'erreur. Les pixels sont simplement faux, et seulement pour le sous-ensemble de pages que quelqu'un a pivotées après coup — exactement le genre de bogue qui survit à une passe QA complète sur un PDF de test non pivoté puis apparaît en production à la page 47 d'un vrai document

Pourquoi PDFium fait-il pivoter la page deux fois ?

PDFium applique automatiquement la propre valeur /Rotate d'une page à chaque fois qu'il rend un bitmap, quel que soit ce qui est transmis au moteur de rendu. Le paramètre rotate de FPDF_RenderPageBitmap, exposé dans PDFiumPas comme les valeurs TRotation ro0, ro90, ro180 et ro270 sur TPdf.RenderPage, TPdf.RenderTile et TPdf.RenderPageThumbnail, ne définit pas l'angle auquel une page devrait finir ; le paramètre rotate définit combien de rotation supplémentaire superposer à ce que le dictionnaire de page spécifie déjà, ce qui explique pourquoi chacune de ces méthodes le règle par défaut à ro0

TPdf.PageRotation lit cette même valeur /Rotate via FPDFPage_GetRotation, et le code applicatif en a souvent besoin pour des raisons qui n'ont rien à voir avec le rendu, comme décider comment disposer une annotation dans l'espace de page. Le piège est une seule ligne : transmettre PageRotation dans l'argument Rotation de RenderPage, en s'attendant à ce que l'appel normalise la page à l'endroit. Une page déjà enregistrée avec /Rotate 90 s'affiche correctement, pivotée, dans toute visionneuse conforme, PDFium inclus ; ajoutez encore ro90 par-dessus et la page bascule à 180 degrés au lieu des 90 prévus, tandis qu'une page sans aucune rotation se voit infliger un quart de tour indésirable sans raison

// 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, []);

À quoi sert réellement le paramètre Rotation

Le paramètre Rotation mérite sa place dans l'API pour un travail véritablement différent : ajouter une rotation de vue uniquement, qui n'a rien à voir avec l'orientation stockée d'une page, le genre qu'un bouton de barre d'outils de rotation de vue applique sans toucher au fichier sous-jacent. TPdfView garde les deux concepts comme deux propriétés séparées exactement pour cette raison. TPdfView.PageRotation reflète le propre /Rotate de la page et, via FPDFPage_SetRotation, peut réécrire une nouvelle valeur dans le document ; TPdfView.Rotation est une propriété transitoire, de vue uniquement, qui vaut ro0 par défaut et ne touche jamais le fichier. Lire la première propriété et l'écrire dans la seconde est tout le bogue en une phrase

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

Pourquoi le dimensionnement du zoom d'ajustement se casse-t-il de la même façon ?

Le dimensionnement du zoom d'ajustement se casse pour une raison en miroir : le calcul part de la mauvaise paire de nombres plutôt que du mauvais angle. Une façon typique de dimensionner une boîte de vignette demande à PDFium la largeur et la hauteur d'une page, compare ce rapport d'aspect à la boîte disponible, et calcule le plus grand rectangle qui y tient — ce qui fonctionne proprement pour une page non pivotée. Le même calcul échoue silencieusement pour une page /Rotate 90 ou /Rotate 270 lorsque la largeur et la hauteur proviennent d'un appel qui rapporte la taille intrinsèque, non pivotée, de la page : une page A4 portrait portant /Rotate 90 rapporte tout de même environ 595 par 842 points, même si PDFium la rend, correctement, à environ 842 par 595 une fois la rotation appliquée, et une boîte d'ajustement calculée à partir de la paire non pivotée finit avec une forme entièrement de la mauvaise orientation

FPDF_GetPageSizeByIndex est un exemple concret d'appel qui rapporte cette taille intrinsèque, non pivotée, par conception, ce qui le rend pratique pour scanner les dimensions de page sans charger chaque page et risqué pour un calcul de zoom d'ajustement qui oublie d'en tenir compte. La correction découle directement de la mise en mots du problème : vérifier la rotation de la page avant de faire l'arithmétique d'ajustement, échanger largeur et hauteur chaque fois que cette rotation vaut 90 ou 270 degrés, calculer la boîte d'ajustement à partir de la paire échangée, et tout de même transmettre ro0 à l'appel de rendu réel, car PDFium reste celui qui applique la vraie rotation

Obtenir des vignettes correctes sans réinventer les calculs d'ajustement

TPdf.RenderPageThumbnail porte déjà cette correction, si bien que le chemin le plus court vers une vignette correcte consiste à l'appeler plutôt qu'à réassembler la logique d'ajustement-et-rotation à la main. Étant donné un index de page basé sur 1 et une largeur et hauteur maximales, RenderPageThumbnail calcule une boîte d'ajustement, la corrige en interne pour un /Rotate de 90 ou 270, et renvoie un bitmap dont l'appelant devient propriétaire sans perturber la page actuelle du document ni déclencher d'événement OnPageChange — ce qui compte pour un bandeau de vignettes construit aux côtés d'une visionneuse active sur la même instance 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'assistant FitBox mérite d'être conservé de toute façon, car RenderPageThumbnail ne couvre que le cas d'un seul bitmap. Une grille de vignettes personnalisée, un bandeau d'aperçu avant impression, ou une boîte de dialogue de sélection de page qui dispose plusieurs pages contre des boîtes indépendantes a besoin du même calcul d'ajustement conscient de la rotation sans nécessairement vouloir un bitmap frais pour chaque tuile, et les propres modes de zoom ajuster-à-la-page et ajuster-à-la-largeur de TPdfView s'appuient en interne sur la même idée, choisissant entre la largeur et la hauteur d'une page pour le calcul du ratio de zoom selon la rotation actuelle de la vue avant de la comparer à la zone client disponible. Si la performance du zoom et du défilement dans ce genre de visionneuse est le prochain problème sur la liste, l'article compagnon sur la mise en cache de rendu et le zoom fluide dans une visionneuse Delphi basée sur PDFium reprend exactement là où le dimensionnement correct s'arrête

Repérer une double rotation avant qu'un client ne le fasse

Une double rotation a une signature visuelle fiable : une page pivotée de 90 degrés à l'entrée ressort avec l'air d'être pivotée de 180 par rapport au reste du document, pas de 90, car le ro90 supplémentaire s'est empilé sur le propre ro90 de la page au lieu de le remplacer. Un fixture de test construit uniquement à partir de pages /Rotate 0 ne détectera jamais cela, puisqu'ajouter ro0 à ro0 reste ro0 et le bogue reste invisible ; un fixture a besoin d'au moins une page enregistrée avec /Rotate 90 et une avec /Rotate 270 avant qu'un chemin de code de vignette ou de zoom d'ajustement puisse être digne de confiance

Le pipeline page-vers-bitmap de base couvert dans le rendu de pages PDF en JPEG avec le composant PDFium rend déjà correctement les pages pivotées sans aucun code de cas particulier, précisément parce qu'il laisse Rotation à sa valeur par défaut ro0 et laisse PDFium appliquer /Rotate de lui-même. Le bogue de double rotation n'apparaît qu'une fois que le code applicatif se met à relire PageRotation et à l'injecter là où il n'a pas sa place

Les appels de rendu conscients de la rotation et le dimensionnement de vignettes décrits ici font partie du composant PDFium pour Delphi et C++Builder, aux côtés du reste des API de rendu, de visualisation, et d'extraction de texte construites sur les mêmes classes TPdf et TPdfView