Article technique

Matrice PDFium prepend vs append en Delphi : pivot

Les matrices affines PDF utilisent la convention vecteur-ligne d'ISO 32000-1 §8.3.3, où un point multiplie la matrice depuis la gauche : point' = point * M. Dans le PDFium Component pour Delphi et C++Builder, ce seul fait fixe toute la surface d'API de TPdfMatrix : Multiply ajoute en fin de composition, donc M := M * Op, tandis que PreMultiply ajoute en tête, donc M := Op * M

Chaque bogue de transformation classique remonte à cette phrase mal mémorisée à l'envers. Le filigrane qui pivote proprement dans votre fichier de test et atterrit à moitié hors page dans le fichier client. La vignette qui ressort pivotée deux fois parce que la page portait déjà un quart de tour. Le tampon dont le décalage est parfait sur A4 et dérive sur Letter. Aucun de ces cas n'est un bogue de rendu ; ce sont des bogues d'ordre de multiplication, et ils sont tous réparables une fois qu'on peut dire à voix haute dans quel espace chaque opération est écrite

La convention vecteur-ligne qui fixe les règles

TPdfMatrix stocke les six éléments nommés par la spécification et les applique exactement comme le format les définit, si bien que la transformation elle-même est le point de départ du raisonnement. TPdfMatrix.TransformPoint calcule x' = x*a + y*c + e et y' = x*b + y*d + f, ce qui est la forme à six éléments qu'ISO 32000-1 §8.3.4 définit pour l'opérateur cm qui concatène une matrice sur la matrice de transformation courante. La paire (a, b) est la première ligne, (c, d) la seconde, et (e, f) la ligne de translation. Les habitudes vecteur-colonne acquises avec OpenGL ou un cours d'algèbre linéaire vous induiront en erreur ici, et elles le feront silencieusement, car une matrice au mauvais ordre reste une matrice parfaitement valide. Lisez un composite dans la convention ligne de gauche à droite et l'ordre d'application se déduit gratuitement : puisque point * (M * Op) équivaut à (point * M) * Op, une opération ajoutée en fin agit sur des coordonnées que la matrice existante a déjà produites, c'est-à-dire dans l'espace page, tandis qu'une opération ajoutée en tête agit avant que la matrice existante ne s'exécute, dans l'espace d'entrée propre de l'objet

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 tourne par défaut dans le sens horaire et en degrés, avec ACounterClockwise et AAngleInRadians disponibles quand vos données source sont signées dans l'autre sens. Les propriétés en lecture seule a à f et la propriété Handle vous rendent la FS_MATRIX brute, ce que FPDFPageObj_SetMatrix attend. Rien dans la classe ne vous cache les six nombres, et c'est délibéré : quand une transformation se comporte mal, imprimer a à f est le diagnostic le plus rapide dont vous disposez

Pourquoi ajouter une translation en tête nécessite-t-il la partie linéaire ?

Parce qu'un décalage ajouté en tête est écrit dans l'espace d'entrée de la matrice, et il doit être porté à travers la partie linéaire courante avant de pouvoir rejoindre la ligne de translation. TPdfMatrix.PreTranslate calcule donc e := dx*a + dy*c + e et f := dx*b + dy*d + f. Ajouter en fin est la direction facile : TPdfMatrix.Translate est écrit dans l'espace page, où rien n'a besoin d'être converti, si bien qu'il ajoute simplement dx à e et dy à f. Quiconque « optimise » PreTranslate en deux additions vient de supprimer la rotation et l'échelle du décalage

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;

La même asymétrie traverse la paire d'échelle, et il vaut la peine de savoir quels éléments chacune touche avant de déboguer l'une d'elles à trois heures du matin. TPdfMatrix.PreScale multiplie des lignes, mettant à l'échelle a et b par scaleX et c et d par scaleY, et laisse la translation intacte car le décalage s'est déjà produit en aval. La méthode d'ajout en fin TPdfMatrix.Scale multiplie des colonnes à la place, prenant a, c, e par scaleX et b, d, f par scaleY, si bien que le décalage existant s'échelonne avec tout le reste. Les deux sont des chemins à but unique qui sautent le produit général à six éléments, et les deux préservent exactement la sémantique de composition de la forme générale

Où vont les deux translations dans une rotation par pivot ?

Autour de l'opération, pas autour de toute la matrice, et dans cet ordre. TPdfMatrix.RotateAt ajoute Translate(-pivot), puis la rotation, puis Translate(+pivot), ce qui sous la convention vecteur-ligne se compose comme Translate(-pivot) * Op * Translate(pivot). Cette séquence est ce qui garde le pivot fixe sous la nouvelle opération tout en laissant la matrice existante produire ses coordonnées en premier et les transmettre. Écrivez la paire dans l'autre sens, comme ce serait correct dans une bibliothèque vecteur-colonne, et l'objet orbite autour de l'origine au lieu de tourner sur place, ce qui est précisément comment un filigrane centré finit hors de la boîte de recadrage

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;

La même composition soutient ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt, et CentralFlipAt, si bien qu'une fois que vous faites confiance au motif pour la rotation, vous pouvez lui faire confiance pour le reste. TPdfMatrix.CentralFlip mérite d'être signalé à part : il inverse le signe des six éléments pour vous donner un demi-tour de 180 degrés sans aucune trigonométrie, ce qui signifie aucun cos d'une valeur qui aurait dû être exactement zéro et aucune dérive accumulée quand vous l'appliquez dans une boucle. Si vous placez des marques répétées plutôt que d'en faire pivoter une seule, la mécanique du placement lui-même est couverte dans les tampons de page réutilisables avec Form XObjects, et le travail de matrice ici repose directement dessus

Que vous apprend TryDecompose sur une matrice ?

TPdfMatrix.TryDecompose rapporte translation, échelle, rotation, cisaillement, déterminant et un indicateur de réflexion sous une convention échelle-puis-rotation, et il les rapporte de façon suffisamment honnête pour être utile à des décisions plutôt que juste à de la journalisation. ScaleX provient de la longueur de la première ligne, Sqrt(a*a + b*b), il est donc toujours positif. ScaleY vaut ensuite Determinant / ScaleX, ce qui le rend signé. La rotation provient d'ArcTan2(-b, a) en degrés, et le cisaillement du produit scalaire des deux lignes normalisé par les deux échelles

Ce signe sur ScaleY est la partie que les gens suppriment, et la supprimer est un vrai bogue plutôt qu'un défaut cosmétique. Un déterminant négatif signifie que la matrice contient une réflexion. Forcez les deux facteurs d'échelle à être positifs pour que les nombres paraissent plus soignés, et vous avez jeté la réflexion, si bien qu'une matrice reconstruite à partir de la décomposition revient mise en miroir : le texte se lit à l'envers, une page numérisée se retourne, un logo importé fait face au mauvais côté. Le champ IsReflected existe pour que vous n'ayez jamais à le déduire. C'est aussi la vérification qui empêche la double rotation classique, où du code ajoute une rotation d'affichage à une page qui en porte déjà une ; la version côté visualiseur de ce problème est traitée dans l'ajustement de vignette, le zoom et la double rotation

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;

Ajuster un rectangle dans un autre sans deviner

TPdfMatrix.TryCreateRectMapping construit la matrice source-vers-destination pour vous et prend un TPdfMatrixFitMode parmi pmfStretch, pmfContain, ou pmfCover. Il normalise d'abord les deux rectangles, car les rectangles PDF ne sont pas tenus d'arriver avec la gauche sous la droite ou le bas sous le haut, puis dérive des échelles X et Y indépendantes : pmfStretch les garde indépendantes, pmfContain prend la plus petite et centre la zone en boîte aux lettres, pmfCover prend la plus grande et centre le recadrage. Le MapRectToRect compagnon ajoute la même correspondance sur une matrice existante, et NewRectMapping lève EPdfMatrixError là où la forme Try retourne False. C'est la primitive sous chaque placement de cellule dans l'imposition N-up et le réordonnancement de pages, où chaque page source doit atterrir à l'intérieur d'une cellule calculée sans que vous ayez à redériver l'arithmétique pour chaque mise en page

Matrices dégénérées et le chemin d'échec honnête

Des entrées finies ne garantissent pas un résultat fini, si bien que le code d'ajustement calcule en Double puis revérifie la candidate rétrécie en Single pour vérifier sa finitude avant de la publier ; une correspondance contenant un infini n'est jamais rendue comme si elle était valide. La même discipline régit l'inversion. TPdfMatrix.TryGetInverse rejette une matrice via un seuil relatif, comparant le déterminant à l'epsilon multiplié par le carré du plus grand élément linéaire plutôt qu'à une constante fixe, ce qui garde le test pertinent que vos unités soient des points ou des micromètres. TryDecompose abandonne de la même façon, refusant quand la longueur de la première ligne ou le ScaleY dérivé tombe à ou en dessous de l'epsilon

Choisissez le style d'échec qui convient au point d'appel plutôt que d'envelopper systématiquement dans un try-except par habitude. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds et TryCreateRectMapping retournent False et laissent leurs cibles intactes, ce qui convient aux tests de collision et aux boucles par objet où un objet dégénéré devrait être sauté, pas fatal. Invert, InverseCopy, InverseTransformPoint, MapRectToRect et TransformBounds lèvent EPdfMatrixError à la place, ce qui convient au code d'initialisation où une matrice singulière signifie que l'appelant a calculé quelque chose de faux. Pour le travail par lots, TransformPoints et TransformRects allouent leur tableau de résultat exactement une fois, TransformPointsInPlace et TransformRectsInPlace réutilisent votre stockage, et TryTransformBounds accumule la boîte englobante en une seule passe plutôt que de matérialiser d'abord les points transformés

Rien de tout cela n'est des mathématiques exotiques. C'est une seule convention, appliquée de façon cohérente, avec une API nommée de sorte que la convention soit visible au point d'appel : Multiply et les verbes ordinaires ajoutent en fin, la famille Pre ajoute en tête, la famille At encadre l'opération avec sa paire de pivot. Notez l'ordre dans un commentaire à côté de tout composite que vous construisez, car le code qui se lit correctement aujourd'hui est le code que quelqu'un inversera dans six mois. La référence complète de TPdfMatrix, ainsi que les API d'objets de page et de rendu que ces transformations alimentent, vivent avec le PDFium Component pour Delphi et C++Builder