Technischer Artikel

PDFium-Doppelrotation und Fit-Zoom-Bugs in Delphi

Die Funktion FPDF_RenderPageBitmap der PDFium-Komponente akzeptiert ein Rotate-Argument, das PDFium immer zusätzlich zu der Rotation addiert, die die Seite bereits in ihrem eigenen /Rotate-Eintrag trägt, sodass das Auslesen der gespeicherten Rotation einer Seite und das erneute Einspeisen desselben Werts in den Render-Aufruf die Seite zweimal rotiert. Derselbe Fehler zeigt sich in der Fit-Zoom-Mathematik: Eine Miniaturansicht aus der unrotierten Breite und Höhe der Seite zu dimensionieren erzeugt das falsche Seitenverhältnis, wann immer /Rotate 90 oder 270 Grad ist, weil die gerenderte Bitmap mit vertauschter Breite und Höhe herauskommt

Der Fehlschlag ist leicht zu erkennen, sobald man weiß, wonach man suchen muss, und leicht zu übersehen, bevor man es weiß. Eine Charge gescannter Rechnungen kommt mit einer Mischung aus Hoch- und Querformat-Originalen an, jemand richtet die Hälfte davon mit einer 90-Grad-Rotation in Acrobat gerade, bevor sie archiviert werden, und der Miniaturansichten-Streifen in einem auf PDFium aufgebauten Delphi-Viewer rendert genau diese Seiten seitwärts, kopfüber oder in eine Box gequetscht, die für die falsche Ausrichtung geformt ist. Nichts wirft eine Exception. Nichts protokolliert einen Fehler. Die Pixel sind einfach falsch, und nur für die Teilmenge von Seiten, die jemand nachträglich rotiert hat — genau die Art von Bug, die einen vollständigen QA-Durchlauf gegen ein unrotiertes Test-PDF übersteht und dann in der Produktion auf Seite 47 einer echten Datei auftaucht

Warum rotiert PDFium die Seite zweimal?

PDFium wendet den eigenen /Rotate-Wert einer Seite bei jedem Rendern einer Bitmap automatisch an, unabhängig davon, was an den Renderer übergeben wird. Der Rotate-Parameter von FPDF_RenderPageBitmap, in PDFiumPas als die TRotation-Werte ro0, ro90, ro180 und ro270 auf TPdf.RenderPage, TPdf.RenderTile und TPdf.RenderPageThumbnail freigelegt, setzt nicht den Winkel, in dem eine Seite enden soll; der Rotate-Parameter setzt, wie viel zusätzliche Rotation über das gelegt wird, was das Seitenwörterbuch bereits angibt, weshalb jede dieser Methoden ihn standardmäßig auf ro0 setzt

TPdf.PageRotation liest denselben /Rotate-Wert über FPDFPage_GetRotation, und Anwendungscode braucht ihn oft aus Gründen, die nichts mit Rendering zu tun haben, etwa um zu entscheiden, wie eine Annotation im Seitenraum platziert wird. Die Falle ist eine einzige Zeile: PageRotation in das Rotation-Argument von RenderPage zu übergeben, in der Erwartung, dass der Aufruf die Seite auf aufrecht normalisiert. Eine bereits mit /Rotate 90 gespeicherte Seite zeigt sich in jedem konformen Viewer korrekt, rotiert, PDFium eingeschlossen; addiert man ro90 nochmal obendrauf, schwenkt die Seite auf 180 Grad statt der beabsichtigten 90, während eine Seite ganz ohne Rotation aus keinem Grund eine ungewollte Vierteldrehung bekommt

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

Wofür der Rotation-Parameter tatsächlich gedacht ist

Der Rotation-Parameter verdient sich seinen Platz in der API für eine tatsächlich andere Aufgabe: das Hinzufügen einer reinen Ansichtsrotation, die nichts mit der gespeicherten Ausrichtung einer Seite zu tun hat, der Art, die ein Rotate-View-Symbolleisten-Button anwendet, ohne die zugrunde liegende Datei anzufassen. TPdfView hält die beiden Konzepte genau aus diesem Grund als zwei separate Eigenschaften. TPdfView.PageRotation spiegelt das eigene /Rotate der Seite und kann über FPDFPage_SetRotation einen neuen Wert zurück in das Dokument schreiben; TPdfView.Rotation ist eine flüchtige, reine Ansichtseigenschaft, die standardmäßig ro0 ist und nie die Datei anfasst. Die erste Eigenschaft zu lesen und sie in die zweite zu schreiben ist der ganze Bug in einem Satz

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

Warum bricht die Fit-Zoom-Dimensionierung auf dieselbe Weise?

Die Fit-Zoom-Dimensionierung bricht aus einem spiegelbildlichen Grund: Die Berechnung startet vom falschen Zahlenpaar statt vom falschen Winkel. Ein typischer Weg, eine Miniaturansichts-Box zu dimensionieren, fragt PDFium nach Breite und Höhe einer Seite, vergleicht dieses Seitenverhältnis mit der verfügbaren Box und berechnet das größte Rechteck, das hineinpasst — was für eine unrotierte Seite sauber funktioniert. Dieselbe Berechnung scheitert still bei einer /Rotate-90- oder /Rotate-270-Seite, wenn Breite und Höhe von einem Aufruf stammen, der die intrinsische, unrotierte Größe der Seite meldet: Eine A4-Hochformat-Seite mit /Rotate 90 meldet weiterhin etwa 595 mal 842 Punkte, obwohl PDFium sie korrekt mit etwa 842 mal 595 rendert, sobald die Rotation greift, und eine aus dem unrotierten Paar berechnete Fit-Box landet komplett in der falschen Ausrichtung geformt

FPDF_GetPageSizeByIndex ist ein konkretes Beispiel für einen Aufruf, der diese intrinsische, unrotierte Größe absichtlich meldet, was es praktisch macht, um Seitenabmessungen zu scannen, ohne jede Seite zu laden, und riskant für Fit-Zoom-Mathematik, die vergisst, das zu berücksichtigen. Die Lösung folgt direkt daraus, das Problem zu benennen: die Rotation der Seite prüfen, bevor die Fit-Arithmetik läuft, Breite und Höhe vertauschen, wann immer diese Rotation 90 oder 270 Grad ist, die Fit-Box aus dem vertauschten Paar berechnen, und trotzdem ro0 an den tatsächlichen Render-Aufruf übergeben, denn PDFium bleibt derjenige, der die echte Rotation anwendet

Miniaturansichten richtig hinbekommen, ohne die Fit-Mathematik neu zu erfinden

TPdf.RenderPageThumbnail trägt diese Lösung bereits, sodass der kürzeste Weg zu einer korrekten Miniaturansicht darin besteht, sie aufzurufen, statt die Fit-und-Rotate-Logik von Hand neu zusammenzusetzen. Bei gegebenem 1-basiertem Seitenindex sowie maximaler Breite und Höhe berechnet RenderPageThumbnail eine Fit-Box, korrigiert sie intern für ein /Rotate von 90 oder 270, und gibt eine dem Aufrufer gehörende Bitmap zurück, ohne die aktuelle Seite des Dokuments zu stören oder ein OnPageChange-Ereignis auszulösen — was zählt für einen Miniaturansichten-Streifen, der neben einem lebenden Viewer auf derselben TPdf-Instanz gebaut ist

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

Der FitBox-Helfer lohnt sich trotzdem zu behalten, weil RenderPageThumbnail nur den Einzel-Bitmap-Fall abdeckt. Ein benutzerdefiniertes Miniaturansichten-Raster, ein Druckvorschau-Streifen oder ein Seitenauswahl-Dialog, der mehrere Seiten gegen unabhängige Boxen anordnet, braucht dieselbe rotationsbewusste Fit-Mathematik, ohne notwendigerweise für jede Kachel eine frische Bitmap zu wollen, und TPdfViews eigene Fit-Page- und Fit-Width-Zoom-Modi stützen sich intern auf dieselbe Idee, indem sie zwischen Breite und Höhe einer Seite für die Zoom-Verhältnis-Berechnung wählen, basierend auf der aktuellen Rotation der Ansicht, bevor sie es mit dem verfügbaren Client-Bereich vergleichen. Falls Zoom- und Scroll-Performance in einem solchen Viewer das nächste Problem auf der Liste ist, knüpft der begleitende Beitrag zu Render-Caching und flüssigem Zoom in einem PDFium-basierten Delphi-Viewer genau dort an, wo korrekte Dimensionierung aufhört

Eine Doppelrotation erkennen, bevor ein Kunde es tut

Eine Doppelrotation hat eine zuverlässige visuelle Signatur: Eine Seite, die auf dem Weg hinein um 90 Grad rotiert wurde, kommt relativ zum Rest des Dokuments um 180 Grad rotiert aussehend heraus, nicht um 90, weil das zusätzliche ro90 sich auf das eigene ro90 der Seite gestapelt hat, statt es zu ersetzen. Eine Testfixture, die nur aus /Rotate-0-Seiten aufgebaut ist, wird das nie fangen, da ro0 addiert zu ro0 weiterhin ro0 ist und der Bug unsichtbar bleibt; eine Fixture braucht mindestens eine mit /Rotate 90 gespeicherte Seite und eine mit /Rotate 270, bevor einem Miniaturansichten- oder Fit-Zoom-Codepfad vertraut werden kann

Die grundlegende Seite-zu-Bitmap-Pipeline, die in dem Rendern von PDF-Seiten zu JPEG mit der PDFium-Komponente behandelt wird, rendert rotierte Seiten bereits korrekt ohne jeden Sonderfall-Code, genau weil sie Rotation bei ihrem ro0-Standard belässt und PDFium /Rotate von sich aus anwenden lässt. Der Doppelrotations-Bug erscheint erst, sobald Anwendungscode beginnt, PageRotation zurückzulesen und es irgendwohin zu füttern, wo es nicht hingehört

Die hier beschriebenen rotationsbewussten Render-Aufrufe und die Miniaturansichten-Dimensionierung sind Teil der PDFium-Komponente für Delphi und C++Builder, zusammen mit dem Rest der Render-, Anzeige- und Textextraktions-APIs, die auf denselben Klassen TPdf und TPdfView aufgebaut sind