Artigo Técnico

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

As matrizes afim do PDF usam a convenção de vetor-linha da ISO 32000-1 §8.3.3, em que um ponto multiplica a matriz pela esquerda: point' = point * M. No PDFium Component para Delphi e C++Builder, esse único facto fixa toda a superfície da API de TPdfMatrix: Multiply anexa, logo M := M * Op, enquanto PreMultiply prefixa, logo M := Op * M

Todo o bug clássico de transformação remonta a essa frase ser lembrada ao contrário. A marca de água que roda perfeitamente no ficheiro de teste e cai meio fora da página no ficheiro do cliente. A miniatura que sai rodada duas vezes porque a página já trazia um quarto de volta. O carimbo cujo deslocamento é perfeito em A4 e vai desviando em Letter. Nenhum destes é um bug de renderização; são bugs de ordem de multiplicação, e todos são corrigíveis assim que se consiga dizer em voz alta em que espaço cada operação está escrita

Diagrama da ordem de multiplicação de matrizes PDF no PDFium Component para Delphi: o Multiply acrescenta operações que atuam no espaço da página, enquanto o PreMultiply antepõe e força a translação pela parte linear
Operadores acrescentados atuam sobre coordenadas que a matriz existente já produziu, enquanto os antepostos atingem a entrada bruta e têm de viajar pela parte linear

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

TPdfMatrix armazena os seis elementos com nome da especificação e aplica-os exatamente como o formato os define, pelo que é aí que 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 na matriz de transformação corrente. 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 num curso de álgebra linear vão induzi-lo em erro aqui, e vão fazê-lo silenciosamente, porque uma matriz com ordem errada continua a ser uma matriz perfeitamente válida. Ler uma composição na convenção de vetor-linha da esquerda para a direita e a ordem de aplicação sai de graça: dado que point * (M * Op) é igual a (point * M) * Op, uma operação anexada atua sobre coordenadas que a matriz existente já produziu, ou seja, no espaço da página, enquanto uma operação prefixada atua antes de a matriz existente correr, no próprio espaço de entrada do objeto

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Ordem de Append: cada chamada atua 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   e depois desloca 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 omissão o sentido horário e graus, com ACounterClockwise e AAngleInRadians disponíveis quando os dados de origem estiverem assinados de outra forma. As propriedades só de leitura a a f e a propriedade Handle devolvem o FS_MATRIX em bruto, que é o que FPDFPageObj_SetMatrix pede. Nada na classe esconde os seis números do programador, e isso é deliberado: quando uma transformação se comporta mal, imprimir a a f é o diagnóstico mais rápido disponível

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

Porque um deslocamento prefixado está escrito no espaço de entrada da matriz, e tem de ser conduzido através da parte linear atual antes de poder juntar-se à linha de translação. TPdfMatrix.PreTranslate calcula, por isso, e := dx*a + dy*c + e e f := dx*b + dy*d + f. Anexar é a direção fácil: TPdfMatrix.Translate está escrito no espaço da página, onde nada precisa de conversão, pelo que apenas soma dx a e e dy a f. Quem "otimiza" PreTranslate reduzindo-o a duas somas acaba de eliminar 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 stamp,
                             //    que depois da rotação aponta para baixo na página
finally
  M.Free;
end;

A mesma assimetria percorre o par de escalas, e vale a pena saber que elementos cada uma toca antes de depurar uma delas à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 a jusante. O anexante TPdfMatrix.Scale multiplica colunas em vez disso, tomando a, c, e por scaleX e b, d, f por scaleY, pelo que o deslocamento existente escala junto com tudo o resto. Ambos são caminhos de propósito único que saltam o produto geral de seis elementos, e ambos preservam exatamente a semântica de composição da forma geral

Onde vão as duas translações numa rotação por pivô?

À volta da operação, não à volta de toda a matriz, e por essa 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 permite que a matriz existente produza primeiro as suas coordenadas e as entregue adiante. Escreva o par pela ordem contrária, como seria correto numa biblioteca de vetor-coluna, e o objeto orbita à volta da origem em vez de girar no lugar, o que é precisamente como uma marca de água centrada acaba fora da crop box

Diagrama de rotação em pivô para TPdfMatrix.RotateAt em Delphi, em que transladar menos pivô, rodar, transladar mais pivô roda um carimbo no lugar, enquanto um par invertido orbita a origem para fora do crop box
O enquadramento RotateAt mantém uma marca de água a girar em torno do seu pivô, enquanto a ordem invertida envia-a a 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, pelo que, uma vez confiando no padrão para a rotação, pode confiar nele para o resto. TPdfMatrix.CentralFlip merece ser destacado: nega os seis elementos para dar uma volta de 180 graus sem qualquer trigonometria, o que significa nenhum cos de um valor que devia ter sido exatamente zero e nenhuma deriva acumulada ao aplicá-lo num ciclo. Se estiver a colocar marcas repetidas em vez de rodar uma só, a mecânica da própria colocação está coberta em carimbos de página reutilizáveis com Form XObjects, e o trabalho de matrizes aqui assenta diretamente sobre esse artigo

O que é que TryDecompose diz sobre uma matriz?

TPdfMatrix.TryDecompose reporta translação, escala, rotação, tosquia (shear), determinante e uma flag de reflexão sob uma convenção de escala-depois-rotação, e reporta-os com honestidade suficiente para servirem decisões e não apenas registo. ScaleX vem do comprimento da primeira linha, Sqrt(a*a + b*b), pelo que é sempre positivo. ScaleY é depois Determinant / ScaleX, o que o torna com sinal. A rotação vem de ArcTan2(-b, a) em graus, e a tosquia das duas linhas, produto escalar normalizado por ambas as escalas

PDFium Component: Mapa de campos de TPdfMatrix.TryDecompose mostrando ScaleY com sinal a partir do determinante, o sinalizador IsReflected e o bug de texto espelhado causado por forçar ambos os fatores de escala a positivos
TryDecompose mantém ScaleY com sinal de propósito, porque limitar 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. Forçar ambos os fatores de escala a positivo para os números parecerem mais arrumados e a reflexão é deitada fora, pelo que uma matriz reconstruída a partir da decomposição volta espelhada: o texto lê-se ao contrário, uma página digitalizada inverte-se, um logótipo importado fica virado para o lado errado. O campo IsReflected existe precisamente para nunca ser preciso inferir isto. Esta é também a verificação que evita a clássica dupla rotação, em que o código soma uma volta de exibição a uma página que já transporta uma; a versão do lado do visualizador desse problema é trabalhada em ajuste de miniatura, zoom e dupla rotação

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX é sempre positivo; D.ScaleY leva 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 a sua própria rotação
  end
  else
    UseIdentityFallback;        // quase singular ou não finito: sem resposta
end;

Encaixar um retângulo noutro sem adivinhar

TPdfMatrix.TryCreateRectMapping constrói por si a matriz de origem para destino e recebe um TPdfMatrixFitMode de pmfStretch, pmfContain, ou pmfCover. Normaliza primeiro os dois retângulos, porque os retângulos PDF não são obrigados a chegar com a esquerda abaixo da direita ou o fundo abaixo do topo, e depois deriva escalas X e Y independentes: pmfStretch mantém-nas independentes, pmfContain toma a menor e centra a faixa (letterbox), pmfCover toma a maior e centra o corte. O companheiro MapRectToRect anexa o mesmo mapeamento a uma matriz existente, e NewRectMapping levanta EPdfMatrixError onde a forma Try devolve False. Este é o primitivo por baixo de cada colocação de célula em imposição N-up e reordenação de páginas, onde cada página de origem tem de caber dentro de uma célula calculada sem ser preciso derivar a aritmética de novo por disposição

Matrizes degeneradas e o caminho honesto de falha

Entradas finitas não garantem um resultado finito, pelo que o código de encaixe calcula em Double e depois volta a verificar o candidato reduzido a Single quanto a ser finito antes de o publicar; 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 com o épsilon multiplicado pelo quadrado do maior elemento linear em vez de contra uma constante fixa, o que mantém o teste com significado quer as unidades sejam pontos ou micrómetros. TryDecompose desiste da mesma forma, recusando quando o comprimento da primeira linha ou o ScaleY derivado ficam no ou abaixo do épsilon

Escolha o estilo de falha que se adequa ao ponto de chamada em vez de envolver tudo em try-except por hábito. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds e TryCreateRectMapping devolvem False e deixam os seus destinos intocados, o que serve bem testes de deteção e ciclos por objeto onde um objeto degenerado deve ser ignorado, não fatal. Invert, InverseCopy, InverseTransformPoint, MapRectToRect e TransformBounds levantam EPdfMatrixError em vez disso, o que serve código de configuração onde uma matriz singular significa que o chamador calculou algo de errado. Para trabalho em lote, TransformPoints e TransformRects alocam o seu array de resultado exatamente uma vez, TransformPointsInPlace e TransformRectsInPlace reutilizam o armazenamento do programador, e TryTransformBounds acumula a caixa delimitadora numa única passagem em vez de materializar primeiro os pontos transformados

Nada disto é matemática exótica. É uma convenção, aplicada de forma consistente, com a API nomeada de modo a que a convenção fique visível no ponto de chamada: Multiply e os verbos simples anexam, a família Pre prefixa, a família At encaixa a operação entre o seu par de pivô. Escreva a ordem num comentário junto a qualquer composição que construa, porque o código que hoje se lê corretamente é o código que alguém inverte daqui a seis meses. A referência completa de TPdfMatrix, junto com as APIs de objetos de página e renderização que estas transformações alimentam, está disponível com o PDFium Component para Delphi e C++Builder