Technischer Artikel

PDFium Matrix Prepend vs Append in Delphi: Pivot-Rotation

PDF-affine Matrizen verwenden die Zeilenvektor-Konvention aus ISO 32000-1 §8.3.3, bei der ein Punkt die Matrix von links multipliziert: point' = point * M. In der PDFium Component für Delphi und C++Builder legt genau diese eine Tatsache die gesamte API-Oberfläche von TPdfMatrix fest: Multiply hängt an, also M := M * Op, während PreMultiply voranstellt, also M := Op * M

Jeder klassische Transform-Bug lässt sich darauf zurückführen, dass dieser Satz rückwärts erinnert wurde. Das Wasserzeichen, das sich in der eigenen Testdatei sauber dreht und in der Kundendatei halb von der Seite fällt. Die Miniaturansicht, die doppelt gedreht herauskommt, weil die Seite bereits eine Vierteldrehung trug. Der Stempel, dessen Offset auf A4 perfekt ist und auf Letter driftet. Keiner davon ist ein Rendering-Bug; es sind Multiplikationsreihenfolge-Bugs, und alle sind behebbar, sobald man laut sagen kann, in welchem Raum jede Operation geschrieben ist

Die Zeilenvektor-Konvention, die die Regeln festlegt

TPdfMatrix speichert die sechs spec-benannten Elemente und wendet sie exakt so an, wie das Format sie definiert, sodass die Transformation selbst der Ausgangspunkt der Überlegung ist. TPdfMatrix.TransformPoint berechnet x' = x*a + y*c + e und y' = x*b + y*d + f, was die sechselementige Form ist, die ISO 32000-1 §8.3.4 für den cm-Operator definiert, der eine Matrix mit der aktuellen Transformationsmatrix verkettet. Das Paar (a, b) ist die erste Zeile, (c, d) die zweite, und (e, f) die Translationszeile. Aus OpenGL oder einem Lineare-Algebra-Kurs mitgebrachte Spaltenvektor-Gewohnheiten führen hier in die Irre, und zwar still, denn eine Matrix in falscher Reihenfolge ist weiterhin eine vollkommen gültige Matrix. Liest man ein Kompositum in der Zeilenkonvention von links nach rechts, ergibt sich die Anwendungsreihenfolge von selbst: Da point * (M * Op) gleich (point * M) * Op ist, wirkt eine angehängte Operation auf Koordinaten, die die bestehende Matrix bereits erzeugt hat, also auf den Seitenraum, während eine vorangestellte Operation wirkt, bevor die bestehende Matrix läuft, im eigenen Eingaberaum des Objekts

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Append order: each call acts on what the previous calls produced.
    M.Scale(0.5, 0.5);                   // M := M * S   half size
    M.Rotate(90);                        // M := M * R   clockwise, degrees
    M.Translate(300, 400);               // M := M * T   then move on the page

    Pt := M.TransformPoint(0, 0);        // x*a + y*c + e, x*b + y*d + f
  finally
    M.Free;
  end;
end;

TPdfMatrix.Rotate ist standardmäßig im Uhrzeigersinn und in Grad, wobei ACounterClockwise und AAngleInRadians verfügbar sind, wenn die eigenen Quelldaten anders signiert sind. Die Nur-Lese-Eigenschaften a bis f und die Eigenschaft Handle geben die rohe FS_MATRIX zurück, was FPDFPageObj_SetMatrix erwartet. Nichts an der Klasse verbirgt die sechs Zahlen; das ist beabsichtigt: Verhält sich eine Transformation daneben, ist das Ausgeben von a bis f die schnellste verfügbare Diagnose

Warum braucht das Voranstellen einer Translation den linearen Teil?

Weil eine vorangestellte Verschiebung im Eingaberaum der Matrix geschrieben ist und durch den aktuellen linearen Teil geführt werden muss, bevor sie sich der Translationszeile anschließen kann. TPdfMatrix.PreTranslate berechnet daher e := dx*a + dy*c + e und f := dx*b + dy*d + f. Anhängen ist die leichte Richtung: TPdfMatrix.Translate ist im Seitenraum geschrieben, wo nichts umgerechnet werden muss, sodass es nur dx zu e und dy zu f addiert. Wer PreTranslate auf zwei Additionen "optimiert", hat gerade Rotation und Skalierung aus der Verschiebung gelöscht

M := TPdfMatrix.Create;
try
  M.Rotate(90);              // a=0, b=-1, c=1, d=0

  M.Translate(10, 0);        // append: e := e + 10
                             // -> 10 points to the right on the page

  M.Reset;
  M.Rotate(90);
  M.PreTranslate(10, 0);     // prepend: e := 10*a + 0*c + e  (unchanged)
                             //          f := 10*b + 0*d + f  (f - 10)
                             // -> 10 points along the stamp own x axis,
                             //    which after the turn points down the page
finally
  M.Free;
end;

Dieselbe Asymmetrie zieht sich durch das Skalierungspaar, und man sollte wissen, welche Elemente jedes davon berührt, bevor man um drei Uhr nachts eines debuggt. TPdfMatrix.PreScale multipliziert Zeilen, skaliert a und b mit scaleX und c und d mit scaleY, und lässt die Translation unangetastet, weil die Verschiebung bereits stromabwärts geschieht. Das anhängende TPdfMatrix.Scale multipliziert stattdessen Spalten, nimmt a, c, e mal scaleX und b, d, f mal scaleY, sodass der bestehende Versatz mit allem anderen skaliert. Beide sind zweckgebundene Pfade, die das allgemeine sechselementige Produkt überspringen, und beide bewahren die Kompositionssemantik der allgemeinen Form exakt

Wo landen die beiden Translationen bei einer Pivot-Rotation?

Um die Operation herum, nicht um die gesamte Matrix, und in dieser Reihenfolge. TPdfMatrix.RotateAt hängt Translate(-pivot) an, dann die Rotation, dann Translate(+pivot), was sich unter der Zeilenvektor-Konvention als Translate(-pivot) * Op * Translate(pivot) zusammensetzt. Diese Sequenz ist es, die den Pivot unter der neuen Operation fixiert hält, während die bestehende Matrix weiterhin zuerst ihre Koordinaten erzeugt und weiterreicht. Schreibt man das Paar umgekehrt, wie es in einer Spaltenvektor-Bibliothek korrekt wäre, umkreist das Objekt den Ursprung statt sich auf der Stelle zu drehen, was genau erklärt, wie ein zentriertes Wasserzeichen außerhalb der Beschneidungsbox landet

procedure RotateStampAboutPageCenter(AObj: FPDF_PAGEOBJECT;
  const AAngleDegrees, APageWidth, APageHeight: Single);
var
  M: TPdfMatrix;
  Raw: FS_MATRIX;
begin
  if not FPDFPageObj_GetMatrix(AObj, Raw) then
    raise Exception.Create('Page object carries no matrix');
  M := TPdfMatrix.Create(Raw);
  try
    // Appends Translate(-pivot) * Rotate * Translate(+pivot) in one call.
    M.RotateAt(AAngleDegrees, APageWidth / 2, APageHeight / 2);
    Raw := M.Handle;
    FPDFPageObj_SetMatrix(AObj, Raw);
  finally
    M.Free;
  end;
end;

Dieselbe Komposition steht hinter ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt und CentralFlipAt, sodass man, hat man dem Muster für Rotation einmal vertraut, ihm auch für den Rest vertrauen kann. TPdfMatrix.CentralFlip ist es wert, gesondert erwähnt zu werden: Es negiert alle sechs Elemente, um eine 180-Grad-Drehung ganz ohne Trigonometrie zu liefern, was keinen cos eines Werts bedeutet, der exakt null hätte sein sollen, und keine sich anhäufende Drift, wenn man es in einer Schleife anwendet. Wer wiederholte Markierungen platziert statt eine zu drehen, dem behandeln die Mechanik der Platzierung selbst wiederverwendbare Seitenstempel mit Form-XObjects, und die hiesige Matrixarbeit sitzt direkt darauf

Was verrät TryDecompose über eine Matrix?

TPdfMatrix.TryDecompose meldet Translation, Skalierung, Rotation, Scherung, Determinante und ein Reflexions-Flag unter einer Skalierung-dann-Rotation-Konvention, und es meldet sie ehrlich genug, um für Entscheidungen brauchbar zu sein, nicht nur zum Loggen. ScaleX stammt aus der Länge der ersten Zeile, Sqrt(a*a + b*b), ist also stets positiv. ScaleY ist dann Determinant / ScaleX, was es signiert macht. Rotation stammt aus ArcTan2(-b, a) in Grad, und Scherung aus dem Skalarprodukt der beiden Zeilen, normalisiert durch beide Skalierungen

Dieses Vorzeichen bei ScaleY ist der Teil, den Leute löschen, und das Löschen ist ein echter Bug, kein kosmetischer. Eine negative Determinante bedeutet, die Matrix enthält eine Spiegelung. Erzwingt man beide Skalierungsfaktoren als positiv, damit die Zahlen ordentlicher aussehen, hat man die Spiegelung weggeworfen, sodass eine aus der Dekomposition rekonstruierte Matrix gespiegelt zurückkommt: Text liest sich rückwärts, eine gescannte Seite klappt um, ein importiertes Logo blickt in die falsche Richtung. Das Feld IsReflected existiert, damit man es nie erschließen muss. Das ist auch die Prüfung, die die klassische Doppel-Rotation verhindert, bei der Code eine Anzeigedrehung zu einer Seite hinzufügt, die bereits eine trägt; die Viewer-seitige Version dieses Problems wird in Miniaturansicht-Anpassung, Zoom und Doppel-Rotation durchgearbeitet

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX is always positive; D.ScaleY carries the determinant sign.
    if D.IsReflected then
      Log('mirrored, ScaleY = %.3f', [D.ScaleY]);

    if Abs(D.RotationDegrees) > 0.5 then
      SkipDisplayRotation;      // the object already carries its own turn
  end
  else
    UseIdentityFallback;        // near-singular or non-finite: no answer
end;

Ein Rechteck ins andere einpassen, ohne zu raten

TPdfMatrix.TryCreateRectMapping baut die Matrix von Quelle zu Ziel selbst und nimmt einen TPdfMatrixFitMode von pmfStretch, pmfContain oder pmfCover entgegen. Es normalisiert zunächst beide Rechtecke, denn PDF-Rechtecke müssen nicht mit links unter rechts oder unten unter oben ankommen, dann leitet es unabhängige X- und Y-Skalierungen ab: pmfStretch hält sie unabhängig, pmfContain nimmt die kleinere und zentriert den Letterbox-Rand, pmfCover nimmt die größere und zentriert den Beschnitt. Das begleitende MapRectToRect hängt dieselbe Abbildung an eine bestehende Matrix an, und NewRectMapping löst EPdfMatrixError aus, wo die Try-Form False zurückgibt. Das ist das Primitiv hinter jeder Zellplatzierung in N-up-Ausschießen und Seiten-Neuordnung, wo jede Quellseite in einer berechneten Zelle landen muss, ohne dass man die Arithmetik pro Layout neu herleitet

Entartete Matrizen und der ehrliche Fehlerpfad

Endliche Eingaben garantieren kein endliches Ergebnis, sodass der Fitting-Code in Double rechnet und dann den verengten Single-Kandidaten erneut auf Endlichkeit prüft, bevor er ihn veröffentlicht; eine Abbildung mit einer Unendlichkeit wird nie so zurückgegeben, als wäre sie gültig. Dieselbe Disziplin regiert die Inversion. TPdfMatrix.TryGetInverse weist eine Matrix anhand eines relativen Schwellenwerts zurück, wobei die Determinante gegen Epsilon mal das Quadrat des größten linearen Elements verglichen wird, statt gegen eine feste Konstante, was die Prüfung sinnvoll hält, egal ob die eigenen Einheiten Punkte oder Mikrometer sind. TryDecompose bricht ebenso ab, verweigert, wenn die Länge der ersten Zeile oder das abgeleitete ScaleY bei oder unter Epsilon liegt

Den Fehlerstil wählen, der zur Aufrufstelle passt, statt alles aus Gewohnheit in try-except zu wickeln. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds und TryCreateRectMapping geben False zurück und lassen ihre Ziele unangetastet, was für Hit-Testing und Pro-Objekt-Schleifen passt, wo ein entartetes Objekt übersprungen werden sollte, nicht fatal sein. Invert, InverseCopy, InverseTransformPoint, MapRectToRect und TransformBounds lösen stattdessen EPdfMatrixError aus, was für Setup-Code passt, bei dem eine singuläre Matrix bedeutet, der Aufrufer hat etwas falsch berechnet. Für Batch-Arbeit reservieren TransformPoints und TransformRects ihr Ergebnis-Array genau einmal, TransformPointsInPlace und TransformRectsInPlace nutzen den eigenen Speicher wieder, und TryTransformBounds akkumuliert die Bounding-Box in einem einzigen Durchlauf, statt zuerst transformierte Punkte zu materialisieren

Nichts davon ist exotische Mathematik. Es ist eine Konvention, konsistent angewendet, mit einer API, deren Namen die Konvention an der Aufrufstelle sichtbar machen: Multiply und die schlichten Verben hängen an, die Pre-Familie stellt voran, die At-Familie klammert die Operation mit ihrem Pivot-Paar ein. Die Reihenfolge in einem Kommentar neben jedem gebauten Kompositum festhalten, denn der Code, der heute korrekt liest, ist der Code, den jemand in sechs Monaten umkehrt. Die vollständige TPdfMatrix-Referenz, zusammen mit den Page-Object- und Rendering-APIs, die diese Transformationen speisen, lebt bei der PDFium Component für Delphi und C++Builder