Artigo Técnico

Rotação Duplicada e Erros de Ajuste de Zoom no PDFium em Delphi

A função FPDF_RenderPageBitmap do PDFium Component aceita um argumento de rotação que o PDFium adiciona sempre à rotação que a página já transporta na sua própria entrada /Rotate, pelo que ler a rotação guardada de uma página e voltar a passar esse mesmo valor na chamada de renderização faz rodar a página duas vezes. O mesmo erro, de forma idêntica, surge na matemática do ajuste de zoom: dimensionar uma miniatura a partir da largura e altura não rodadas da página produz o rácio de aspeto errado sempre que /Rotate seja 90 ou 270 graus, porque o bitmap renderizado sai com a largura e a altura trocadas

A falha é fácil de identificar depois de se saber o que procurar, e fácil de passar despercebida até lá. Chega um lote de faturas digitalizadas com uma mistura de originais em retrato e paisagem, alguém endireita metade delas com uma rotação de 90 graus no Acrobat antes de as arquivar, e a faixa de miniaturas de um visualizador Delphi construído sobre o PDFium renderiza essas páginas em particular de lado, ao contrário, ou espremidas numa caixa com a forma da orientação errada. Nada gera uma exceção. Nada regista um erro. Os pixels estão simplesmente errados, e apenas para o subconjunto de páginas que alguém rodou posteriormente — exatamente o tipo de erro que sobrevive a uma bateria completa de testes de QA contra um PDF de teste sem rotações e depois surge em produção na página 47 de um ficheiro real

Porque é que o PDFium roda a página duas vezes?

O PDFium aplica automaticamente o valor /Rotate próprio de uma página sempre que renderiza um bitmap, independentemente do que é passado ao renderizador. O parâmetro de rotação do FPDF_RenderPageBitmap, exposto no PDFiumPas como os valores TRotation ro0, ro90, ro180 e ro270 no TPdf.RenderPage, TPdf.RenderTile e TPdf.RenderPageThumbnail, não define o ângulo com que a página deve acabar; o parâmetro de rotação define quanta rotação extra sobrepor àquilo que o dicionário da página já especifica, razão pela qual todos estes métodos o assumem por predefinição como ro0

O TPdf.PageRotation lê esse mesmo valor /Rotate através de FPDFPage_GetRotation, e o código da aplicação muitas vezes precisa dele por razões que nada têm a ver com renderização, como decidir como posicionar uma anotação no espaço da página. A armadilha está numa única linha: passar PageRotation para o argumento Rotation de RenderPage, esperando que a chamada normalize a página para a posição correta. Uma página já guardada com /Rotate 90 é apresentada corretamente, rodada, em qualquer visualizador conforme, incluindo o PDFium; acrescentar ro90 outra vez por cima disso faz a página rodar 180 graus em vez dos 90 pretendidos, enquanto uma página sem qualquer rotação recebe uma volta de um quarto indesejada sem motivo

Diagrama de uma chamada de renderização PDFium Delphi em que o parâmetro de rotação aditivo transforma uma página gravada com /Rotate 90 em 180 graus, enquanto ro0 a renderiza corretamente
O PDFium acrescenta o argumento rotate por cima da entrada /Rotate da própria página, pelo que passar PageRotation de volta para a chamada de renderização transforma uma página de 90 graus em 180 graus
// Errado: PageRotation já reflete /Rotate, e o PDFium aplica
// -a automaticamente em cada renderização -- voltar a passá-lo como Rotation
// duplica o ângulo
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);

// Certo: deixe Rotation no predefinido ro0 e deixe que o PDFium aplique o
// /Rotate próprio da página exatamente uma vez
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);

Para que serve efetivamente o parâmetro Rotation

O parâmetro Rotation justifica o seu lugar na API para uma tarefa genuinamente diferente: acrescentar uma rotação apenas de visualização que nada tem a ver com a orientação guardada de uma página, o tipo que um botão de rotação na barra de ferramentas aplica sem tocar no ficheiro subjacente. O TPdfView mantém os dois conceitos como duas propriedades separadas exatamente por esta razão. O TPdfView.PageRotation espelha o /Rotate próprio da página e, através de FPDFPage_SetRotation, pode escrever um novo valor de volta no documento; o TPdfView.Rotation é uma propriedade transitória, apenas de visualização, que assume ro0 por predefinição e nunca toca no ficheiro. Ler a primeira propriedade e escrevê-la na segunda é o erro inteiro numa única frase

Diagrama contrastando a propriedade persistente PageRotation com a propriedade Rotation só de vista no TPdfView no PDFium Component para Delphi
TPdfView guarda o valor /Rotate armazenado em PageRotation e a rotação só de vista em Rotation; ler um no outro é todo o bug
// Apenas visual: roda o que o utilizador vê, não altera nada no ficheiro
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
  case PdfView.Rotation of
    ro0:   PdfView.Rotation := ro90;
    ro90:  PdfView.Rotation := ro180;
    ro180: PdfView.Rotation := ro270;
    ro270: PdfView.Rotation := ro0;
  end;
end;

// Persistente: reescreve a entrada /Rotate própria da página no documento
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
  case PdfView.PageRotation of
    ro0:   PdfView.PageRotation := ro90;
    ro90:  PdfView.PageRotation := ro180;
    ro180: PdfView.PageRotation := ro270;
    ro270: PdfView.PageRotation := ro0;
  end;
end;

Porque é que o dimensionamento por ajuste de zoom falha da mesma forma?

O dimensionamento por ajuste de zoom falha por uma razão em espelho: o cálculo parte do par de números errado, em vez do ângulo errado. Uma forma típica de dimensionar uma caixa de miniatura consiste em pedir ao PDFium a largura e a altura de uma página, comparar esse rácio de aspeto com a caixa disponível, e calcular o maior retângulo que nela cabe — o que funciona sem problemas para uma página sem rotação. O mesmo cálculo falha silenciosamente numa página com /Rotate 90 ou /Rotate 270 quando a largura e a altura vêm de uma chamada que reporta o tamanho intrínseco e não rodado da página: uma página A4 em retrato com /Rotate 90 continua a reportar aproximadamente 595 por 842 pontos, mesmo que o PDFium a renderize, corretamente, a aproximadamente 842 por 595 assim que a rotação é aplicada, e uma caixa de ajuste calculada a partir do par não rodado acaba com uma forma completamente da orientação errada

FPDF_GetPageSizeByIndex é um exemplo concreto de uma chamada que, por conceção, reporta esse tamanho intrínseco e não rodado, o que a torna conveniente para percorrer as dimensões das páginas sem carregar cada uma delas, e arriscada para a matemática de ajuste de zoom que se esqueça de o considerar. A correção decorre diretamente de nomear o problema: verificar a rotação da página antes de fazer a aritmética do ajuste, trocar largura por altura sempre que essa rotação seja de 90 ou 270 graus, calcular a caixa de ajuste a partir do par trocado, e continuar a passar ro0 à chamada de renderização real, porque é o PDFium que continua a aplicar a rotação verdadeira

Diagrama do dimensionamento de zoom de ajuste PDFium em Delphi, em que uma página com /Rotate 90 tem de ter a largura e a altura trocadas antes de calcular a caixa de ajuste
Uma página com /Rotate 90 reporta 595 por 842 pontos mas renderiza a 842 por 595, pelo que a matemática de fit-zoom troca largura e altura em páginas de 90 e 270 graus

Obter miniaturas corretas sem reinventar a matemática de ajuste

O TPdf.RenderPageThumbnail já contém esta correção, pelo que o caminho mais curto para uma miniatura correta é chamá-lo em vez de remontar manualmente a lógica de ajuste e rotação. Dado um índice de página de base 1 e uma largura e altura máximas, o RenderPageThumbnail calcula uma caixa de ajuste, corrige-a internamente para um /Rotate de 90 ou 270, e devolve um bitmap de posse do chamador sem perturbar a página atual do documento nem disparar um evento OnPageChange — o que importa numa faixa de miniaturas construída a par de um visualizador ativo na mesma instância de TPdf

// PageW, PageH são as dimensões próprias (não rodadas) de uma página em pontos,
// por exemplo de FPDF_GetPageSizeByIndex, que reporta o tamanho antes
// de /Rotate ser aplicado
function FitBox(PageW, PageH: Double; Rotation: TRotation;
  MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
  PgW, PgH, Swap: Integer;
begin
  PgW := Round(PageW);
  PgH := Round(PageH);
  if PgW < 1 then PgW := 1;
  if PgH < 1 then PgH := 1;

  if Rotation in [ro90, ro270] then
  begin
    Swap := PgW;
    PgW := PgH;
    PgH := Swap;
  end;

  Result := (MaxW > 0) and (MaxH > 0);
  if not Result then
    Exit;

  if PgW * MaxH > PgH * MaxW then
  begin
    FitW := MaxW;
    FitH := (MaxW * PgH) div PgW;
  end
  else
  begin
    FitH := MaxH;
    FitW := (MaxH * PgW) div PgH;
  end;
end;

Vale a pena manter o auxiliar FitBox de qualquer forma, porque o RenderPageThumbnail só cobre o caso de um único bitmap. Uma grelha de miniaturas personalizada, uma faixa de pré-visualização de impressão, ou uma caixa de diálogo de seleção de páginas que dispõe várias páginas em caixas independentes precisa da mesma matemática de ajuste sensível à rotação, sem necessariamente querer um bitmap novo para cada mosaico, e os próprios modos de zoom ajustar-à-página e ajustar-à-largura do TPdfView apoiam-se internamente na mesma ideia, escolhendo entre a largura e a altura de uma página para o cálculo do rácio de zoom com base na rotação atual da vista antes de a comparar com a área de cliente disponível. Se o desempenho de zoom e deslocamento nesse tipo de visualizador for o próximo problema da lista, o artigo relacionado sobre cache de renderização e zoom fluido num visualizador Delphi baseado em PDFium continua exatamente onde o dimensionamento correto termina

Detetar uma rotação duplicada antes que um cliente o faça

Uma rotação duplicada tem uma assinatura visual fiável: uma página que foi rodada 90 graus à entrada acaba com um aspeto rodado 180 graus em relação ao resto do documento, não 90, porque o ro90 extra se sobrepôs ao ro90 próprio da página em vez de o substituir. Um conjunto de testes construído apenas com páginas /Rotate 0 nunca vai apanhar isto, já que somar ro0 a ro0 continua a ser ro0 e o erro permanece invisível; um conjunto de testes precisa de pelo menos uma página guardada com /Rotate 90 e outra com /Rotate 270 antes de um caminho de código de miniaturas ou de ajuste de zoom poder ser considerado fiável

O pipeline básico de página para bitmap abordado em renderizar páginas de PDF para JPEG com o PDFium Component já renderiza páginas rodadas corretamente sem qualquer código especial, precisamente porque deixa Rotation na sua predefinição ro0 e deixa o PDFium aplicar /Rotate por conta própria. O erro de rotação duplicada só aparece quando o código da aplicação começa a ler PageRotation de volta e a passá-lo para um sítio a que não pertence

As chamadas de renderização sensíveis à rotação e o dimensionamento de miniaturas aqui descritos fazem parte do PDFium Component para Delphi e C++Builder, a par do restante conjunto de APIs de renderização, visualização e extração de texto construídas sobre as mesmas classes TPdf e TPdfView