Artículo técnico

Matriz PDFium: Prepend vs Append en Delphi y rotación pivote

Las matrices afines de PDF usan la convención de vector fila de ISO 32000-1 §8.3.3, donde un punto multiplica a 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 anexa, así que M := M * Op, mientras que PreMultiply antepone, así que M := Op * M

Cada error clásico de transformación se remonta a que esa frase se recordó al revés. La marca de agua que rota perfectamente en tu archivo de prueba y cae medio fuera de la página en el archivo del cliente. La miniatura que sale rotada dos veces porque la página ya llevaba un cuarto de vuelta. El sello cuyo desplazamiento es perfecto en A4 y se desvía en Letter. Ninguno de esos son errores de renderizado; son errores de orden de multiplicación, y todos son corregibles una vez que 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 nombrados por la especificación y los aplica exactamente como los define el formato, así que la propia transformación es donde comienza el razonamiento. TPdfMatrix.TransformPoint calcula x' = x*a + y*c + e y 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 de OpenGL o de 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: dado que point * (M * Op) es igual a (point * M) * Op, una operación anexada actúa sobre coordenadas que la matriz existente ya produjo, es decir, en el espacio de la página, mientras que una operación antepuesta actúa antes de que corra la matriz existente, en el espacio de entrada propio 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 están firmados al revés. Las propiedades de solo lectura a a f y la propiedad Handle te devuelven el FS_MATRIX 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 transportarse a través de la parte lineal actual antes de poder unirse a la fila de traslación. TPdfMatrix.PreTranslate, por lo tanto, calcula e := dx*a + dy*c + e y f := dx*b + dy*d + f. Anexar es la dirección fácil: TPdfMatrix.Translate está escrito en el espacio de la página, donde no hay nada que convertir, así que solo suma dx a e y dy a f. Cualquiera que "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 vale la pena saber qué elementos toca cada uno antes de depurar uno a las tres de la mañana. TPdfMatrix.PreScale multiplica filas, escalando a y b por scaleX y c y d por scaleY, y deja en paz a la traslación porque el desplazamiento ya sucedió río abajo. El TPdfMatrix.Scale que anexa 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 pivote

Alrededor de la operación, no alrededor de toda la matriz, y en ese orden. TPdfMatrix.RotateAt anexa Translate(-pivot), luego la rotación, luego Translate(+pivot), que bajo la convención de vector fila compone como Translate(-pivot) * Op * Translate(pivot). Esa secuencia es lo que mantiene fijo al pivote bajo la nueva operación mientras aún permite que la matriz existente produzca sus coordenadas primero y las entregue. Escribe el par al revés, como sería correcto en una librería de vector columna, y el objeto orbita el origen en lugar de girar en su lugar, que es precisamente cómo una marca de agua centrada termina fuera de la caja de recorte

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 vale la pena señalarlo 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, la mecánica de la colocación en sí se cubre en sellos de página reutilizables con Form XObjects, y el trabajo de matrices aquí se apoya directamente sobre eso

Qué te dice TryDecompose sobre una matriz

TPdfMatrix.TryDecompose reporta traslación, escala, rotación, cizalladura, determinante y un indicador de reflexión bajo una convención de escala-luego-rotación, y los reporta con la honestidad suficiente para ser útiles en decisiones y no solo para registro. ScaleX proviene 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 proviene 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 elimina, y eliminarlo es un error real en lugar de uno cosmético. Un determinante negativo significa que la matriz contiene una reflexión. Fuerza ambos factores de escala a ser positivos para que los números se vean más ordenados y habrás descartado la reflexión, 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 también es la comprobación que evita la clásica doble rotación, donde el código agrega 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;

Encajar un rectángulo en 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 debajo de la derecha o el fondo debajo de la parte superior, luego 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 complementario MapRectToRect anexa el mismo mapeo sobre una matriz existente, y NewRectMapping lanza EPdfMatrixError donde la forma Try devuelve False. Este es el primitivo detrás de cada colocación de celda en la imposición N-up y el reordenamiento de páginas, donde cada página de origen tiene que aterrizar dentro de una celda calculada sin que vuelvas a derivar la aritmética por cada diseño

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 luego vuelve a comprobar la finitud del candidato Single ya reducido antes de publicarlo; un mapeo que contiene 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 significativa la prueba ya sea que tus unidades sean puntos o micrómetros. TryDecompose se retira de la misma manera, rechazando cuando la longitud de la primera fila o el ScaleY derivado cae en o por debajo del épsilon

Elige el estilo de fallo que se ajuste al punto de llamada en lugar de envolver todo en try-except por costumbre. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds y TryCreateRectMapping devuelven False y dejan sus destinos intactos, lo que conviene para pruebas de impacto y 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 que conviene para código de configuración donde una matriz singular significa que quien invoca calculó algo mal. Para trabajo por lotes, TransformPoints y TransformRects asignan su arreglo de resultado exactamente una vez, TransformPointsInPlace y TransformRectsInPlace reutilizan tu almacenamiento, y TryTransformBounds acumula el cuadro delimitador en un solo paso 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 para que la convención sea visible en el punto de llamada: Multiply y los verbos simples anexan, la familia Pre antepone, la familia At encierra la operación con 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 en seis meses. La referencia completa de TPdfMatrix, junto con las API de objetos de página y renderizado que alimentan estas transformaciones, vive con el PDFium Component para Delphi y C++Builder