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

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
    // 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 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 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;

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

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

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 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;

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