Articolo tecnico

Matrice PDFium Prepend vs Append in Delphi: Rotazione Pivot

Le matrici affini PDF usano la convenzione row-vector di ISO 32000-1 §8.3.3, dove un punto moltiplica la matrice da sinistra: point' = point * M. Nel PDFium Component per Delphi e C++Builder quel singolo fatto fissa l'intera superficie API di TPdfMatrix: Multiply accoda, quindi M := M * Op, mentre PreMultiply antepone, quindi M := Op * M

Ogni classico bug di trasformazione risale a quella frase ricordata al contrario. Il watermark che ruota perfettamente nel tuo file di test e finisce mezzo fuori pagina nel file del cliente. La miniatura che esce ruotata due volte perché la pagina portava già un quarto di giro. Il timbro il cui offset è perfetto su A4 e deriva su Letter. Nessuno di questi è un bug di rendering; sono bug di ordine di moltiplicazione, e sono tutti risolvibili una volta che riesci a dire ad alta voce in quale spazio è scritta ogni operazione

La convenzione row-vector che fissa le regole

TPdfMatrix memorizza i sei elementi nominati dalla specifica e li applica esattamente come li definisce il formato, quindi è dalla trasformazione stessa che parte il ragionamento. TPdfMatrix.TransformPoint calcola x' = x*a + y*c + e e y' = x*b + y*d + f, che è la forma a sei elementi che ISO 32000-1 §8.3.4 definisce per l'operatore cm che concatena una matrice sulla matrice di trasformazione corrente. La coppia (a, b) è la prima riga, (c, d) la seconda, e (e, f) la riga di traslazione. Le abitudini column-vector prese da OpenGL o da un corso di algebra lineare ti trarranno in inganno qui, e lo faranno silenziosamente, perché una matrice con l'ordine sbagliato è comunque una matrice perfettamente valida. Leggi un composito nella convenzione row da sinistra a destra e l'ordine di applicazione emerge gratis: poiché point * (M * Op) equivale a (point * M) * Op, un'operazione accodata agisce su coordinate che la matrice esistente ha già prodotto, cioè nello spazio pagina, mentre un'operazione anteposta agisce prima che la matrice esistente venga eseguita, nello spazio di input proprio dell'oggetto

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 ha come default orario e in gradi, con ACounterClockwise e AAngleInRadians disponibili quando i tuoi dati sorgente sono firmati nell'altro verso. Le proprietà di sola lettura da a a f e la proprietà Handle ti restituiscono la FS_MATRIX grezza, che è ciò che vuole FPDFPageObj_SetMatrix. Nulla nella classe ti nasconde i sei numeri, ed è deliberato: quando una trasformazione si comporta male, stampare da a a f è la diagnosi più veloce che hai

Perché anteporre una traslazione richiede la parte lineare?

Perché uno spostamento anteposto è scritto nello spazio di input della matrice, e deve essere portato attraverso la parte lineare corrente prima di poter unirsi alla riga di traslazione. TPdfMatrix.PreTranslate quindi calcola e := dx*a + dy*c + e e f := dx*b + dy*d + f. Accodare è la direzione facile: TPdfMatrix.Translate è scritto nello spazio pagina, dove nulla deve essere convertito, quindi aggiunge solo dx a e e dy a f. Chiunque "ottimizzi" PreTranslate riducendolo a due addizioni ha appena cancellato la rotazione e la scala dallo spostamento

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 stessa asimmetria attraversa la coppia di scala, e vale la pena sapere quali elementi tocca ciascuna prima di debuggarne una alle tre di notte. TPdfMatrix.PreScale moltiplica le righe, scalando a e b per scaleX e c e d per scaleY, e lascia stare la traslazione perché lo spostamento è già avvenuto a valle. La TPdfMatrix.Scale che accoda invece moltiplica le colonne, prendendo a, c, e per scaleX e b, d, f per scaleY, così l'offset esistente scala insieme a tutto il resto. Entrambe sono percorsi a scopo singolo che saltano il prodotto generale a sei elementi, ed entrambe preservano esattamente la semantica di composizione della forma generale

Dove vanno le due traslazioni in una rotazione a pivot?

Attorno all'operazione, non attorno all'intera matrice, e in quell'ordine. TPdfMatrix.RotateAt accoda Translate(-pivot), poi la rotazione, poi Translate(+pivot), che sotto la convenzione row-vector si compone come Translate(-pivot) * Op * Translate(pivot). Quella sequenza è ciò che mantiene il pivot fisso sotto la nuova operazione pur lasciando che la matrice esistente produca prima le sue coordinate e le passi avanti. Scrivi la coppia al contrario, come sarebbe corretto in una libreria column-vector, e l'oggetto orbita attorno all'origine invece di girare sul posto, il che è esattamente come un watermark centrato finisce fuori dal crop box

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 stessa composizione sta dietro ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt, e CentralFlipAt, così una volta che ti fidi del pattern per la rotazione puoi fidarti per il resto. TPdfMatrix.CentralFlip merita di essere segnalata a parte: nega tutti e sei gli elementi per darti un giro di 180 gradi senza alcuna trigonometria, il che significa nessun cos di un valore che avrebbe dovuto essere esattamente zero e nessuna deriva accumulata quando la applichi in un ciclo. Se stai posizionando marchi ripetuti anziché girarne uno, la meccanica del posizionamento stesso è trattata in timbri di pagina riutilizzabili con Form XObject, e il lavoro sulla matrice qui poggia direttamente sopra di esso

Cosa ti dice TryDecompose su una matrice?

TPdfMatrix.TryDecompose riporta traslazione, scala, rotazione, shear, determinante e un flag di riflessione sotto una convenzione scala-poi-rotazione, e li riporta abbastanza onestamente da essere utili per decisioni anziché solo per logging. ScaleX deriva dalla lunghezza della prima riga, Sqrt(a*a + b*b), quindi è sempre positivo. ScaleY è quindi Determinant / ScaleX, il che lo rende con segno. La rotazione deriva da ArcTan2(-b, a) in gradi, e lo shear dal prodotto scalare delle due righe normalizzato da entrambe le scale

Quel segno su ScaleY è la parte che le persone cancellano, e cancellarlo è un vero bug anziché uno cosmetico. Un determinante negativo significa che la matrice contiene una riflessione. Forza entrambi i fattori di scala a essere positivi per far sembrare i numeri più ordinati e hai buttato via la riflessione, così una matrice ricostruita dalla decomposizione torna specchiata: il testo si legge al contrario, una pagina scansionata si ribalta, un logo importato guarda dal lato sbagliato. Il campo IsReflected esiste così non devi mai dedurlo. Questo è anche il controllo che previene la classica doppia rotazione, in cui il codice aggiunge un giro di visualizzazione a una pagina che ne porta già uno; la versione lato viewer di quel problema è trattata in adattamento miniatura, zoom, e doppia rotazione

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;

Adattare un rettangolo in un altro senza indovinare

TPdfMatrix.TryCreateRectMapping costruisce per te la matrice sorgente-verso-destinazione e prende un TPdfMatrixFitMode tra pmfStretch, pmfContain, o pmfCover. Normalizza prima entrambi i rettangoli, perché i rettangoli PDF non sono tenuti ad arrivare con sinistra sotto destra o basso sotto alto, poi deriva scale X e Y indipendenti: pmfStretch le mantiene indipendenti, pmfContain prende quella più piccola e centra il letterbox, pmfCover prende quella più grande e centra il ritaglio. Il compagno MapRectToRect accoda la stessa mappatura su una matrice esistente, e NewRectMapping solleva EPdfMatrixError dove la forma Try restituisce False. Questo è il primitivo sotto ogni posizionamento di cella in imposizione N-up e riordino pagine, dove ogni pagina sorgente deve atterrare dentro una cella calcolata senza che tu debba riderivare l'aritmetica per ogni layout

Matrici degeneri e il percorso di fallimento onesto

Input finiti non garantiscono un risultato finito, quindi il codice di fitting calcola in Double e poi riverifica la finitezza del candidato Single ristretto prima di pubblicarlo; una mappatura contenente un infinito non viene mai restituita come se fosse valida. La stessa disciplina governa l'inversione. TPdfMatrix.TryGetInverse rifiuta una matrice usando una soglia relativa, confrontando il determinante contro l'epsilon moltiplicato per il quadrato dell'elemento lineare più grande anziché contro una costante fissa, il che mantiene il test significativo indipendentemente dal fatto che le tue unità siano punti o micrometri. TryDecompose abbandona allo stesso modo, rifiutando quando la lunghezza della prima riga o la ScaleY derivata cade a o sotto l'epsilon

Scegli lo stile di fallimento adatto al punto di chiamata anziché avvolgere tutto in try-except per abitudine. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds e TryCreateRectMapping restituiscono False e lasciano intatti i loro target, il che si adatta all'hit-testing e ai cicli per-oggetto dove un oggetto degenere dovrebbe essere saltato, non fatale. Invert, InverseCopy, InverseTransformPoint, MapRectToRect e TransformBounds sollevano invece EPdfMatrixError, il che si adatta al codice di setup dove una matrice singolare significa che il chiamante ha calcolato qualcosa di sbagliato. Per il lavoro batch, TransformPoints e TransformRects allocano il proprio array di risultato esattamente una volta, TransformPointsInPlace e TransformRectsInPlace riutilizzano il tuo storage, e TryTransformBounds accumula il bounding box in un singolo passaggio anziché materializzare prima i punti trasformati

Nulla di tutto questo è matematica esotica. È una sola convenzione, applicata in modo coerente, con l'API nominata così la convenzione è visibile al punto di chiamata: Multiply e i verbi semplici accodano, la famiglia Pre antepone, la famiglia At racchiude l'operazione con la sua coppia pivot. Scrivi l'ordine in un commento accanto a qualunque composito costruisci, perché il codice che si legge correttamente oggi è il codice che qualcuno inverte tra sei mesi. Il riferimento completo di TPdfMatrix, insieme alle API di page-object e rendering in cui confluiscono queste trasformazioni, si trova con il PDFium Component per Delphi e C++Builder