Artigo Técnico

Matriz PDFium Prepend vs Append em Delphi: Rotação por Pivô

Matrizes afins do PDF usam a convenção de vetor-linha da ISO 32000-1 §8.3.3, na qual um ponto multiplica a matriz pela esquerda: point' = point * M. No PDFium Component para Delphi e C++Builder, esse único fato define toda a superfície de API do TPdfMatrix: Multiply anexa, então M := M * Op, enquanto PreMultiply prefixa, então M := Op * M

Todo bug clássico de transformação remonta a essa frase lembrada ao contrário. A marca d'água que gira perfeitamente no seu arquivo de teste e cai pela metade fora da página no arquivo do cliente. A miniatura que sai girada duas vezes porque a página já carregava um quarto de volta. O carimbo cujo deslocamento é perfeito no A4 e derrapa no Letter. Nenhum desses é um bug de renderização; são bugs de ordem de multiplicação, e todos são corrigíveis assim que você consegue dizer em voz alta em qual espaço cada operação está escrita

Diagrama da ordem de multiplicação de matrizes de PDF no PDFium Component para Delphi: Multiply anexa operações agindo no espaço da página, enquanto PreMultiply antepõe e força o deslocamento pela parte linear
Operadores acrescentados atuam sobre coordenadas que a matriz existente já produziu, enquanto os precedidos atingem a entrada bruta e precisam atravessar a parte linear

A convenção de vetor-linha que define as regras

TPdfMatrix armazena os seis elementos nomeados pela especificação e os aplica exatamente como o formato os define, então a transformação em si é onde o raciocínio começa. TPdfMatrix.TransformPoint calcula x' = x*a + y*c + e e y' = x*b + y*d + f, que é a forma de seis elementos que a ISO 32000-1 §8.3.4 define para o operador cm que concatena uma matriz à matriz de transformação atual. O par (a, b) é a primeira linha, (c, d) a segunda, e (e, f) a linha de translação. Hábitos de vetor-coluna adquiridos com OpenGL ou com um curso de álgebra linear vão te enganar aqui, e vão te enganar silenciosamente, porque uma matriz na ordem errada ainda é uma matriz perfeitamente válida. Leia um composto na convenção de linha da esquerda para a direita e a ordem de aplicação sai de graça: já que point * (M * Op) equivale a (point * M) * Op, uma operação anexada age sobre coordenadas que a matriz existente já produziu, ou seja, no espaço da página, enquanto uma operação prefixada age antes que a matriz existente rode, no espaço de entrada próprio do objeto

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Ordem de append: cada chamada age sobre o que as anteriores produziram
    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   depois anda na página

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

TPdfMatrix.Rotate assume por padrão sentido horário e graus, com ACounterClockwise e AAngleInRadians disponíveis quando seus dados de origem têm o sinal invertido. As propriedades somente leitura de a a f e a propriedade Handle te devolvem o FS_MATRIX bruto, que é o que FPDFPageObj_SetMatrix espera. Nada na classe esconde os seis números de você, e isso é proposital: quando uma transformação se comporta mal, imprimir de a a f é o diagnóstico mais rápido que você tem

Por que prefixar uma translação precisa da parte linear?

Porque um deslocamento prefixado é escrito no espaço de entrada da matriz, e precisa ser levado através da parte linear atual antes de poder se juntar à linha de translação. TPdfMatrix.PreTranslate, portanto, calcula e := dx*a + dy*c + e e f := dx*b + dy*d + f. Anexar é a direção fácil: TPdfMatrix.Translate é escrito no espaço da página, onde nada precisa ser convertido, então ele apenas soma dx a e e dy a f. Quem "otimiza" o PreTranslate reduzindo-o a duas somas acabou de apagar a rotação e a escala do deslocamento

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

  M.Translate(10, 0);        // append: e := e + 10
                             // -> 10 pontos para a direita na página

  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 pontos ao longo do eixo x do próprio carimbo,
                             //    que depois do giro aponta para baixo na página
finally
  M.Free;
end;

A mesma assimetria percorre o par de escala, e vale a pena saber quais elementos cada um afeta antes de você depurar um deles às três da manhã. TPdfMatrix.PreScale multiplica linhas, escalando a e b por scaleX e c e d por scaleY, e deixa a translação intacta porque o deslocamento já aconteceu adiante. O TPdfMatrix.Scale, que anexa, multiplica colunas em vez disso, tomando a, c, e por scaleX e b, d, f por scaleY, então o deslocamento existente escala junto com tudo o mais. Ambos são caminhos de propósito único que pulam o produto geral de seis elementos, e ambos preservam exatamente a semântica de composição da forma geral

Onde ficam as duas translações em uma rotação por pivô?

Em torno da operação, não em torno da matriz inteira, e nessa ordem. TPdfMatrix.RotateAt anexa Translate(-pivot), depois a rotação, depois Translate(+pivot), o que sob a convenção de vetor-linha se compõe como Translate(-pivot) * Op * Translate(pivot). Essa sequência é o que mantém o pivô fixo sob a nova operação enquanto ainda deixa a matriz existente produzir suas coordenadas primeiro e repassá-las adiante. Escreva o par na ordem inversa, como seria correto em uma biblioteca de vetor-coluna, e o objeto orbita a origem em vez de girar no lugar, que é exatamente como uma marca d'água centralizada acaba fora da caixa de corte

Diagrama de rotação por pivô para TPdfMatrix.RotateAt em Delphi em que transladar menos o pivô, rotacionar, transladar mais o pivô gira um carimbo no lugar, enquanto um par invertido orbita a origem para fora do crop box
O colchete de RotateAt mantém uma marca d'água girando em torno de seu pivô, enquanto a ordem invertida a manda orbitar para fora da caixa 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;

A mesma composição sustenta ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt e CentralFlipAt, então uma vez que você confia no padrão para rotação, pode confiar nele para o resto. TPdfMatrix.CentralFlip merece destaque à parte: ele nega os seis elementos para dar a você um giro de 180 graus sem nenhuma trigonometria, o que significa nenhum cos de um valor que deveria ser exatamente zero e nenhum desvio acumulado quando você o aplica em um loop. Se você está posicionando marcas repetidas em vez de girar uma só, a mecânica do posicionamento em si está coberta em carimbos de página reutilizáveis com Form XObjects, e o trabalho de matriz aqui se apoia diretamente sobre isso

O que o TryDecompose te diz sobre uma matriz?

TPdfMatrix.TryDecompose relata translação, escala, rotação, cisalhamento, determinante e uma flag de reflexão sob uma convenção de escala-depois-rotação, e relata isso honestamente o suficiente para ser útil para decisões, não apenas para logging. ScaleX vem do comprimento da primeira linha, Sqrt(a*a + b*b), então é sempre positivo. ScaleY é então Determinant / ScaleX, o que o torna com sinal. A rotação vem de ArcTan2(-b, a) em graus, e o cisalhamento do produto escalar das duas linhas normalizado pelas duas escalas

PDFium Component: mapa de campos de TPdfMatrix.TryDecompose mostrando ScaleY com sinal a partir do determinante, o flag IsReflected e o bug de texto espelhado causado por forçar ambos os fatores de escala positivos
TryDecompose mantém ScaleY com sinal de propósito, porque forçar ambos os fatores de escala a positivos espelha silenciosamente qualquer matriz refletida

Esse sinal em ScaleY é a parte que as pessoas apagam, e apagá-lo é um bug real, não cosmético. Um determinante negativo significa que a matriz contém uma reflexão. Force os dois fatores de escala a serem positivos para os números parecerem mais arrumados e você jogou fora a reflexão, então uma matriz reconstruída a partir da decomposição volta espelhada: o texto lê ao contrário, uma página escaneada vira, um logo importado fica virado para o lado errado. O campo IsReflected existe para que você nunca precise inferir isso. Essa é também a verificação que evita a clássica rotação dupla, em que o código adiciona um giro de exibição a uma página que já carrega um; a versão do lado do visualizador desse problema é trabalhada em ajuste de miniatura, zoom e rotação dupla

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX é sempre positivo; D.ScaleY carrega o sinal do determinante
    if D.IsReflected then
      Log('mirrored, ScaleY = %.3f', [D.ScaleY]);

    if Abs(D.RotationDegrees) > 0.5 then
      SkipDisplayRotation;      // o objeto já traz o próprio giro
  end
  else
    UseIdentityFallback;        // quase singular ou não finito: sem resposta
end;

Encaixando um retângulo em outro sem adivinhar

TPdfMatrix.TryCreateRectMapping constrói a matriz de origem para destino para você e recebe um TPdfMatrixFitMode de pmfStretch, pmfContain ou pmfCover. Ele normaliza os dois retângulos primeiro, porque retângulos PDF não são obrigados a chegar com a esquerda abaixo da direita ou a base abaixo do topo, e então deriva escalas X e Y independentes: pmfStretch as mantém independentes, pmfContain usa a menor e centraliza a tarja, pmfCover usa a maior e centraliza o corte. O complemento MapRectToRect anexa o mesmo mapeamento a uma matriz existente, e NewRectMapping lança EPdfMatrixError onde a forma Try retorna False. Essa é a primitiva por trás de todo posicionamento de célula em imposição N-up e reordenação de páginas, onde cada página de origem precisa cair dentro de uma célula calculada sem que você tenha que re-derivar a aritmética por layout

Matrizes degeneradas e o caminho honesto de falha

Entradas finitas não garantem um resultado finito, então o código de ajuste calcula em Double e depois reverifica o candidato Single reduzido quanto à finitude antes de publicá-lo; um mapeamento contendo um infinito nunca é devolvido como se fosse válido. A mesma disciplina rege a inversão. TPdfMatrix.TryGetInverse rejeita uma matriz usando um limiar relativo, comparando o determinante contra o epsilon multiplicado pelo quadrado do maior elemento linear em vez de contra uma constante fixa, o que é o que mantém o teste significativo seja lá se suas unidades são pontos ou micrômetros. TryDecompose desiste da mesma forma, recusando quando o comprimento da primeira linha ou o ScaleY derivado cai em ou abaixo do epsilon

Escolha o estilo de falha que se encaixa no local de chamada, em vez de envolver tudo em try-except por hábito. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds e TryCreateRectMapping retornam False e deixam seus alvos intocados, o que serve bem a hit-testing e loops por objeto, onde um objeto degenerado deve ser ignorado, não ser fatal. Invert, InverseCopy, InverseTransformPoint, MapRectToRect e TransformBounds lançam EPdfMatrixError em vez disso, o que serve bem a código de configuração onde uma matriz singular significa que o chamador calculou algo errado. Para trabalho em lote, TransformPoints e TransformRects alocam o array de resultado exatamente uma vez, TransformPointsInPlace e TransformRectsInPlace reutilizam seu armazenamento, e TryTransformBounds acumula a caixa delimitadora em uma única passagem em vez de materializar pontos transformados primeiro

Nada disso é matemática exótica. É uma única convenção, aplicada de forma consistente, com a API nomeada de modo que a convenção fique visível no local da chamada: Multiply e os verbos simples anexam, a família Pre prefixa, a família At encapsula a operação com seu par de pivô. Anote a ordem em um comentário ao lado de qualquer composto que você construir, porque o código que lê corretamente hoje é o código que alguém inverte daqui a seis meses. A referência completa do TPdfMatrix, junto com as APIs de objeto de página e renderização que essas transformações alimentam, vive junto ao PDFium Component para Delphi e C++Builder