Artículo técnico

Prepend vs Append de matriz PDFium en Delphi: Pivote

Las matrices afines de PDF usan la convención de vector fila de ISO 32000-1 §8.3.3, donde un punto multiplica la matriz desde la izquierda: point' = point * M. En el PDFium Component para Delphi y C++Builder ese único hecho fija toda la superficie de la API de TPdfMatrix: Multiply añade, así que M := M * Op, mientras que PreMultiply antepone, así que M := Op * M

Cada bug clásico de transformación se remonta a que esa frase se recordó al revés. El watermark que rota limpiamente en tu fichero de prueba y aterriza medio fuera de página en el fichero del cliente. La miniatura que sale rotada dos veces porque la página ya llevaba un cuarto de giro. El sello cuyo desplazamiento es perfecto en A4 y se desvía en Letter. Ninguno de esos es un bug de renderizado; son bugs de orden de multiplicación, y todos son corregibles en cuanto puedes decir en voz alta en qué espacio está escrita cada operación

La convención de vector fila que fija las reglas

TPdfMatrix almacena los seis elementos con nombre de la especificación y los aplica exactamente como los define el formato, así que la propia transformación es donde empieza el razonamiento. TPdfMatrix.TransformPoint calcula x' = x*a + y*c + e e y' = x*b + y*d + f, que es la forma de seis elementos que ISO 32000-1 §8.3.4 define para el operador cm que concatena una matriz sobre la matriz de transformación actual. El par (a, b) es la primera fila, (c, d) la segunda, y (e, f) la fila de traslación. Los hábitos de vector columna adquiridos con OpenGL o con un curso de álgebra lineal te engañarán aquí, y te engañarán en silencio, porque una matriz con el orden equivocado sigue siendo una matriz perfectamente válida. Lee un compuesto en la convención de fila de izquierda a derecha y el orden de aplicación se deduce gratis: como point * (M * Op) es igual a (point * M) * Op, una operación añadida actúa sobre coordenadas que la matriz existente ya ha producido, es decir, sobre el espacio de página, mientras que una operación antepuesta actúa antes de que se ejecute la matriz existente, en el propio espacio de entrada del objeto

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 por defecto es en sentido horario y en grados, con ACounterClockwise y AAngleInRadians disponibles cuando tus datos de origen tienen el signo al revés. Las propiedades de solo lectura a a f y la propiedad Handle te devuelven el FS_MATRIX en crudo, que es lo que quiere FPDFPageObj_SetMatrix. Nada en la clase te oculta los seis números, y eso es deliberado: cuando una transformación se comporta mal, imprimir a a f es el diagnóstico más rápido que tienes

¿Por qué anteponer una traslación necesita la parte lineal?

Porque un desplazamiento antepuesto está escrito en el espacio de entrada de la matriz, y tiene que pasar por la parte lineal actual antes de poder unirse a la fila de traslación. TPdfMatrix.PreTranslate por tanto calcula e := dx*a + dy*c + e y f := dx*b + dy*d + f. Añadir es la dirección fácil: TPdfMatrix.Translate está escrito en espacio de página, donde no hay nada que convertir, así que solo suma dx a e y dy a f. Quien "optimice" PreTranslate reduciéndolo a dos sumas acaba de eliminar la rotación y la escala del desplazamiento

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 misma asimetría recorre el par de escala, y merece la pena saber qué elementos toca cada uno antes de depurar uno a las tres de la madrugada. TPdfMatrix.PreScale multiplica filas, escalando a y b por scaleX y c y d por scaleY, y deja intacta la traslación porque el desplazamiento ya ocurrió río abajo. El TPdfMatrix.Scale que añade multiplica columnas en su lugar, tomando a, c, e por scaleX y b, d, f por scaleY, así que el desplazamiento existente escala junto con todo lo demás. Ambas son rutas de propósito único que se saltan el producto general de seis elementos, y ambas preservan exactamente la semántica de composición de la forma general

¿Dónde van las dos traslaciones en una rotación con pivote?

Alrededor de la operación, no alrededor de toda la matriz, y en ese orden. TPdfMatrix.RotateAt añade Translate(-pivot), después la rotación, después Translate(+pivot), que bajo la convención de vector fila compone como Translate(-pivot) * Op * Translate(pivot). Esa secuencia es lo que mantiene el pivote fijo bajo la nueva operación mientras sigue dejando que la matriz existente produzca primero sus coordenadas y las entregue. Escribe el par al revés, como sería correcto en una librería de vector columna, y el objeto orbita alrededor del origen en lugar de girar en su sitio, que es precisamente cómo un watermark centrado acaba fuera del 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 misma composición respalda a ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt, y CentralFlipAt, así que una vez que confías en el patrón para la rotación puedes confiar en él para el resto. TPdfMatrix.CentralFlip merece destacarse aparte: niega los seis elementos para darte un giro de 180 grados sin ninguna trigonometría en absoluto, lo que significa ningún cos de un valor que debería haber sido exactamente cero y ninguna deriva acumulada cuando lo aplicas en un bucle. Si estás colocando marcas repetidas en lugar de girar una sola, la mecánica de la propia colocación se cubre en sellos de página reutilizables con Form XObjects, y el trabajo de matrices de aquí se apoya directamente sobre él

¿Qué te dice TryDecompose sobre una matriz?

TPdfMatrix.TryDecompose reporta traslación, escala, rotación, cizalladura, determinante y un flag de reflexión bajo una convención de escala-y-luego-rotación, y lo reporta con la honestidad suficiente como para ser útil en decisiones y no solo en logs. ScaleX viene de la longitud de la primera fila, Sqrt(a*a + b*b), así que siempre es positivo. ScaleY es entonces Determinant / ScaleX, lo que lo hace con signo. La rotación viene de ArcTan2(-b, a) en grados, y la cizalladura del producto punto de las dos filas normalizado por ambas escalas

Ese signo en ScaleY es la parte que la gente borra, y borrarla es un bug real, no algo cosmético. Un determinante negativo significa que la matriz contiene una reflexión. Fuerza ambos factores de escala a positivos para que los números parezcan más ordenados y habrás tirado la reflexión por la borda, así que una matriz reconstruida a partir de la descomposición vuelve reflejada: el texto se lee al revés, una página escaneada se voltea, un logo importado mira hacia el lado equivocado. El campo IsReflected existe para que nunca tengas que inferirlo. Esta es también la comprobación que evita la clásica doble rotación, donde el código añade un giro de visualización a una página que ya lleva uno; la versión del lado del visor de ese problema se desarrolla en ajuste de miniatura, zoom y doble rotación

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;

Ajustar un rectángulo dentro de otro sin adivinar

TPdfMatrix.TryCreateRectMapping construye por ti la matriz de origen a destino y recibe un TPdfMatrixFitMode de pmfStretch, pmfContain, o pmfCover. Primero normaliza ambos rectángulos, porque los rectángulos de PDF no están obligados a llegar con la izquierda por debajo de la derecha o la parte inferior por debajo de la superior, y después deriva escalas X e Y independientes: pmfStretch las mantiene independientes, pmfContain toma la más pequeña y centra el letterbox, pmfCover toma la más grande y centra el recorte. El acompañante MapRectToRect añade el mismo mapeo sobre una matriz existente, y NewRectMapping lanza EPdfMatrixError donde la forma Try devuelve False. Este es el primitivo que hay debajo de cada colocación de celda en la imposición N-up y reordenación de páginas, donde cada página de origen tiene que aterrizar dentro de una celda calculada sin que tengas que rederivar la aritmética por cada layout

Matrices degeneradas y la ruta de fallo honesta

Las entradas finitas no garantizan un resultado finito, así que el código de ajuste calcula en Double y después revuelve a comprobar la finitud del candidato Single ya reducido antes de publicarlo; un mapeo que contenga un infinito nunca se devuelve como si fuera válido. La misma disciplina gobierna la inversión. TPdfMatrix.TryGetInverse rechaza una matriz usando un umbral relativo, comparando el determinante contra el épsilon multiplicado por el cuadrado del elemento lineal más grande en lugar de contra una constante fija, que es lo que mantiene el test con sentido tanto si tus unidades son puntos como si son micrómetros. TryDecompose se retira de la misma manera, rehusando cuando la longitud de la primera fila o el ScaleY derivado caen en o por debajo del épsilon

Elige el estilo de fallo que encaje con el punto de llamada en lugar de envolver todo en try-except por costumbre. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds y TryCreateRectMapping devuelven False y dejan intactos sus destinos, lo cual encaja con el hit-testing y los bucles por objeto donde un objeto degenerado debería omitirse, no ser fatal. Invert, InverseCopy, InverseTransformPoint, MapRectToRect y TransformBounds lanzan EPdfMatrixError en su lugar, lo cual encaja con el código de configuración donde una matriz singular significa que el llamante calculó algo mal. Para trabajo por lotes, TransformPoints y TransformRects asignan su array de resultado exactamente una vez, TransformPointsInPlace y TransformRectsInPlace reutilizan tu almacenamiento, y TryTransformBounds acumula la caja delimitadora en una sola pasada en lugar de materializar primero los puntos transformados

Nada de esto es matemática exótica. Es una convención, aplicada de forma consistente, con la API nombrada de modo que la convención sea visible en el punto de llamada: Multiply y los verbos simples añaden, la familia Pre antepone, la familia At encierra la operación entre su par de pivote. Escribe el orden en un comentario junto a cualquier compuesto que construyas, porque el código que se lee correctamente hoy es el código que alguien invierte dentro de seis meses. La referencia completa de TPdfMatrix, junto con las APIs de objeto de página y renderizado a las que alimentan estas transformaciones, vive con el PDFium Component para Delphi y C++Builder