Technischer Artikel

PDF-Seitenrotation in Delphi abflachen: Boxen unversehrt

HotPDF flacht die PDF-Seitenrotation mit THotPDF.FlattenLoadedPageRotation ab: Die Methode wickelt den Content jeder gedrehten Seite in eine im Uhrzeigersinn drehende cm-Transformation, schreibt jede Seitenbox um, die die Seite tatsächlich hat, dreht Annotationsgeometrie, Appearance-Matrizen, explizite Destinationen und getaggte Strukturgeometrie um denselben Winkel und setzt dann /Rotate auf 0. Im Viewer sieht die Seite identisch aus, aber ihr Koordinatensystem ist jetzt aufrecht. Das wird relevant, sobald ein nachgelagertes Tool, ein Print-RIP oder Ihr eigener Stempelcode /Rotate ignoriert und Dinge im rohen User Space platziert

Der typische Auslöser ist ein Scanner oder eine Mobile-Capture-App, die Querformatseiten als Hochformat-Medium mit /Rotate 90 schreibt. Jeder Viewer zeigt sie korrekt, niemand merkt also etwas, bis jemand unten rechts eine Seitenzahl stempelt und sie entlang der linken Kante seitlich landet, oder ein Impositionsschritt, der nur /MediaBox liest, für eine Querformatseite einen Hochformat-Slot anlegt. Abflachen klingt nach einer Einzeiler-Matrix-Aufgabe. In der Praxis rührt es fünf Seitenboxen, drei Sorten Annotationsgeometrie, die Link-Ziele des Dokuments und den Strukturbaum an, und jedes davon hat seine eigene Regel in ISO 32000-1

In welche Richtung dreht /Rotate eine PDF-Seite?

/Rotate dreht die Seite für Anzeige und Druck im Uhrzeigersinn, in Vielfachen von 90 Grad (ISO 32000-1 §7.7.3.3, Tabelle 30). Bei 90 Grad wird die linke Kante des Mediums zur Oberkante und die Oberkante zur rechten Seite, in einem y-abwärts gerichteten Device Space lautet die Abbildung also X = (y - Bottom) * Scale und Y = (x - Left) * Scale. Bei 270 Grad wird die rechte Kante zur Oberkante. /Rotate ist zudem eines von nur vier vererbbaren Seitenattributen, zusammen mit /Resources, /MediaBox und /CropBox (§7.7.3.4), ein Seiten-Dictionary ohne eigenes /Rotate kann also weiterhin von einem /Pages-Vorfahren gedreht werden. THotPDF.GetLoadedPageRotation läuft die /Parent-Kette entlang und normalisiert das Ergebnis auf 0-359 — dieser Wert ist es, den Sie wollen, nicht der rohe Schlüssel auf der Seite

Die Richtung lässt sich auf eine Weise vertauschen, die Tests überlebt, und frühere HotPDF-Builds haben exakt das getan. Die alte Seite-zu-Gerät-Matrix vertauschte bei 90 und 270 die y-Komponenten, was eine Spiegelung an der Diagonalen statt einer Drehung ergibt: Die Orientierung der Matrix kippt gegenüber dem ungedrehten Fall. Beide Winkel „sahen gedreht aus“, die Bitmap hatte die vertauschte Breite und Höhe, und ein Hin und zurück von Seite zu Ansicht lieferte den Startpunkt — Dimensionsprüfungen und Roundtrip-Tests liefen also alle durch. Die einzige verlässliche Prüfung ist, wo eine Eckmarke landet, Pixel für Pixel gegen einen Referenz-Renderer verglichen. Weil Viewer-Modell, SIMD-Render-Backend und Highlight-Mapping dieselbe Matrix kopiert hatten, wurden alle zusammen korrigiert, und der Abflach-Code benutzt jetzt dieselbe Uhrzeigersinn-Konvention wie der Renderer

Wie HotPDF die Seitenrotation in Delphi abflacht: Eine als /Rotate 90 gespeicherte Hochformatseite erscheint im Uhrzeigersinn als 792-mal-612-Querformatansicht, die Device-Abbildung X = (y - Bottom) * Scale, Y = (x - Left) * Scale bewegt jede Ecke, und das Vertauschen der Matrix-y-Komponenten ergibt eine Spiegelung, die nur ein Eckmarken-Vergleich erwischt
Viewer drehen die Seite für die Anzeige im Uhrzeigersinn, während die Bytes Hochformat bleiben — GetLoadedPageRotation läuft zuerst die /Parent-Kette entlang, denn /Rotate ist eines der vier vererbbaren Seitenattribute

Wie FlattenLoadedPageRotation eine Seite umschreibt

FlattenLoadedPageRotation(PageRange, Info) bearbeitet jede Seite in PageRange, deren effektive Rotation 90, 180 oder 270 ist, und liefert die Anzahl der abgeflachten Seiten. Ein leerer PageRange heißt alle Seiten; sonst benutzt der String die übliche einbasierte '1-3,7'-Syntax, und eine Seitennummer außerhalb des Bereichs wirft eine Exception, statt übersprungen zu werden. Die ursprünglichen Content-Streams werden nie neu kodiert. Die Methode stellt der /Contents der Seite einen neuen Stream mit q 0 -1 1 0 -Bottom Width+Left cm (bei 90 Grad) voran, hängt einen Stream mit Q an und schreibt zum Schluss ein explizites /Rotate 0 ins Seiten-Dictionary, damit ein vererbter Wert auf einem /Pages-Knoten die Seite nicht ein zweites Mal drehen kann

var
  Pdf: THotPDF;
  Info: THPDFRotationFlattenInfo;
  Flattened: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
    begin
      // '' = alle Seiten; Seiten bei 0 Grad werden gescannt, aber in Ruhe gelassen
      Flattened := Pdf.FlattenLoadedPageRotation('', Info);
      Writeln(Format('Scanned %d, flattened %d pages', [Info.ScannedPageCount, Info.FlattenedPageCount]));
      Writeln(Format('Turned %d annotations, %d destinations, %d tagged geometry entries',
        [Info.TransformedAnnotationCount, Info.TransformedDestinationCount,
         Info.TransformedStructureGeometryCount]));
      if Flattened > 0 then
        Pdf.SaveLoadedDocument('scanned-batch-upright.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Der THPDFRotationFlattenInfo-Record lohnt es, geloggt statt weggeworfen zu werden. ScannedPageCount ist die Größe des Bereichs, FlattenedPageCount entspricht dem Rückgabewert, und die drei Transformed...-Zähler verraten, ob das Dokument Links, Lesezeichen oder getaggte Geometrie auf die gedrehten Seiten hatte. Ein Batch, in dem jede Datei null Destinationen meldet, ist in Ordnung; eine getaggte PDF/UA-Datei, die null Strukturgeometrie meldet, obwohl Sie Figuren-Bounding-Boxen erwartet hätten, ist ein Signal, sie von Hand anzusehen

Welche Seitenboxen schreibt das Abflachen um, und in welcher Reihenfolge?

Das Abflachen schreibt nur die Boxen um, die die Seite bereits hat, und liest jede Box, bevor es irgendeine schreibt. Die Reihenfolge ist wegen der Default-Kette wichtig: GetLoadedPageBox(PageIndex, pbCropBox, ...) liefert die /MediaBox, wenn die Seite keine /CropBox hat, und /BleedBox, /TrimBox und /ArtBox fallen auf die CropBox zurück (§14.11.2). Eine frühere Version las, transformierte und schrieb jeweils eine Box nach der anderen. Sie schrieb zuerst die MediaBox um, las dann die „CropBox“, bekam die bereits gedrehte MediaBox zurück, drehte sie ein zweites Mal und schrieb eine CropBox, die die Seite nie hatte — was eine Querformatseite auf ein Quadrat zuschnitt. Die Vererbungsregeln spalten sich genauso: MediaBox und CropBox werden über die /Parent-Kette nachgeschlagen, während Bleed, Trim und ArtBox nur zählen, wenn sie direkt auf dem Seiten-Dictionary sitzen, ein verirrtes /TrimBox auf einem /Pages-Knoten gilt also als fehlend und wird nie auf die Seite kopiert

procedure DumpPageGeometry(Pdf: THotPDF; PageIndex: Integer);
var
  L, B, R, T: Single;
begin
  Writeln('Effective /Rotate: ', Pdf.GetLoadedPageRotation(PageIndex));
  if Pdf.GetLoadedPageBox(PageIndex, pbMediaBox, L, B, R, T) then
    Writeln(Format('MediaBox [%g %g %g %g]', [L, B, R, T]));
  // True sogar ohne /TrimBox-Schlüssel: der Wert fällt auf CropBox zurück, dann MediaBox
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Brief voreingestellt; GetLoadedPageVisibleBox lässt die Ausgaben bei Scheitern unangetastet
  L := 0; B := 0; R := 612; T := 792;
  Pdf.GetLoadedPageVisibleBox(PageIndex, L, B, R, T);
  Writeln(Format('Visible  [%g %g %g %g]', [L, B, R, T]));
end;

Führen Sie diesen Helfer vor und nach dem Abflachen aus, dann erklären die Zahlen sich selbst. Für eine 90-Grad-Seite mit MediaBox [0 0 612 792] wird die abgeflachte MediaBox zu [0 0 792 612]; jede umgeschriebene Box wird durch dieselbe Drehung im Uhrzeigersinn abgebildet, relativ zum Ursprung der ursprünglichen MediaBox, die neue MediaBox beginnt also immer am Ursprung, und die anderen Boxen behalten ihre Position darin. GetLoadedPageVisibleBox liefert, was Viewer anzeigen und Drucker drucken — die auf die MediaBox beschnittene und normalisierte CropBox, sodass Left kleiner als Right ist — und HotPDFs Renderer, SVG-Export, Viewer und Druckpfad benutzen alle dieselbe Box. Wenn Sie die Größe brauchen, die ein Mensch sieht, rufen Sie GetLoadedPageVisibleBox auf, statt /MediaBox zu lesen

Warum HotPDF bei FlattenLoadedPageRotation jede Seitenbox liest, bevor es irgendeine schreibt: BleedBox, TrimBox und ArtBox fallen auf die CropBox zurück, die selbst auf die MediaBox zurückfällt — Boxen einzeln zu drehen ließ die CropBox also die bereits umgeschriebene MediaBox lesen, ein zweiter Dreh schrieb eine Box, die die Seite nie hatte, und schnitt eine Querformatseite auf ein Quadrat zu
Die Default-Kette bedeutet, dass die Ausgabe einer Box die Eingabe einer anderen ist — erst alles lesen, gegen den ursprünglichen MediaBox-Ursprung transformieren, dann schreiben

Warum brechen Annotationen, wenn man nur /Rect dreht?

Annotationen brechen, weil ein Appearance-Stream nicht direkt in /Rect gezeichnet wird. Unter §12.5.5 transformiert der Viewer zuerst die /BBox der Form mit ihrer /Matrix, skaliert und verschiebt dann das Bounding Box dieses Ergebnisses in /Rect. Dreht man nur /Rect, wird ein 200-mal-40-Stempel in einen 40-mal-200-Slot gequetscht — unlesbar und auf der Seite liegend. FlattenLoadedPageRotation multipliziert deshalb die Uhrzeigersinn-Drehung der Seite rechts an jede Appearance-/Matrix (bei 90 Grad [0 -1 1 0 0 0] in der Zeilenvektorkonvention), über die /N-, /R- und /D-Appearances und jeden Zustand darin. Ein Appearance-Stream kann von mehreren Annotationen oder Zuständen geteilt werden, jeder Stream wird also pro Aufruf exakt einmal gedreht. Der eine Fall ohne saubere Antwort ist ein Stream, der über Seiten mit unterschiedlichen Rotationen geteilt wird; er folgt der ersten Seite, die ihn erreicht

Zwei weitere Regeln halten Formfelder und Haftnotizen an ihrem Platz. Der /MK /R-Eintrag eines Widgets (§12.5.6.19) ist ein Gegenuhrzeiger-Winkel, der Uhrzeigersinn-Winkel der Seite wird also modulo 360 davon abgezogen; überspringt man das, zeichnet die nächste Appearance-Regenerierung den Feldtext in die falsche Richtung. Annotationen mit dem NoRotate-Flag (Bitposition 5, Wert 16, §12.5.3) bleiben auf einer gedrehten Seite aufrecht und pendeln um die obere linke Ecke ihrer /Rect, das Abflachen behält also Breite, Höhe und aufrechtes Erscheinen und verschiebt nur diese Ecke dorthin, wo die Drehung sie hinsetzt. Jenseits der Annotationen dreht die Methode auch /QuadPoints, /Vertices, /L und /InkList, schreibt explizite Destinationen um, die die Seite benennen (/XYZ-Punkte, /FitR-Rechtecke, und /FitH / /FitV bei 90 und 270 Grad vertauscht, §12.3.2.2), und transformiert getaggte Geometrie wie Attribut-/BBox-Einträge für Strukturelemente, deren /Pg die Seite ist

Warum Annotationen brechen, wenn eine HotPDF-Seite allein durch Drehen von /Rect abgeflacht wird: Ein 200-mal-40-Stempel wird in einen 40-mal-200-Slot skaliert und unlesbar, also multipliziert FlattenLoadedPageRotation die Uhrzeigersinn-Drehung rechts an jede Appearance-/Matrix über /N, /R und /D, passt den Gegenuhrzeiger-/MK /R an und pendelt NoRotate-Annotationen um ihre obere linke Ecke
Der Viewer passt die transformierte BBox der Appearance in /Rect ein, der Stream selbst muss also gedreht werden — ein Durchgang pro geteilter Appearance, exakt einmal pro Aufruf

Was deckt das Abflachen nicht ab?

Das Abflachen ist eine geometrische Umschreibung der eigenen Objekte einer Seite, und mehrere Situationen fallen leise statt laut aus ihm heraus

  • Seiten, deren effektive Rotation bereits 0 ist oder deren MediaBox fehlt oder null Breite oder Höhe hat, werden ohne Fehler übersprungen; vergleichen Sie den Rückgabewert mit der Anzahl der Seiten, von denen Sie eine Änderung erwartet hatten
  • Form-XObjects, die von den Seitenressourcen referenziert werden, behalten ihre eigene /BBox im Formraum, denn das äußere cm dreht sie bereits; der Strukturbaum-Scan folgt nur /K und /A und läuft also nie ein zweites Mal in die Seitenressourcen oder Annotationen hinein
  • Destinationen werden gefunden, indem pro abgeflachter Seite jedes indirekte Objekt einmal gescannt wird — ein großes Dokument mit Hunderten gedrehter Seiten zahlt diesen Rundgang für jede einzelne
  • HotPDFs Seiten-Renderer zeichnet keine Annotationen, eine visuelle Prüfung gedrehter Stempel braucht also zuerst FlattenLoadedAnnotations
// Appearance-Daten in den Content einbrennen, damit der Renderer sie zeigen kann,
// dann Seite 1 rendern, vor und nach dem Entfernen von /Rotate
Pdf.FlattenLoadedAnnotations('1');
Before := Pdf.RenderLoadedPageToBitmap(0, 96);
try
  Pdf.FlattenLoadedPageRotation('1', Info);
  After := Pdf.RenderLoadedPageToBitmap(0, 96);
  try
    Assert((Before.Width = After.Width) and (Before.Height = After.Height));
    // Hier Eckmarken-Pixel vergleichen, nicht nur die Abmessungen
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

Für tieferen Hintergrund: Die Annotationsseite dieser Geschichte geht weiter in Annotation-Appearances synthetisieren, bevor sie abgeflacht werden, der Renderer hinter dem Vorher-Nachher-Vergleich wird in eine geladene PDF-Seite in eine Bitmap rendern behandelt, und Redaktion und N-up-Stitching auf geladenen PDFs zeigt dieselbe Content-Stream-Anhang-Technik, auf der sich das Rotations-Präfix und -Suffix stützen. HotPDF, inklusive FlattenLoadedPageRotation und der Seitenbox-Leser, ist für Delphi und C++Builder auf der HotPDF Delphi PDF component-Seite erhältlich