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

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

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

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

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