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
// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);
// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
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
// View-only: rotates what the user sees, changes nothing in the file
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;
// Persistent: rewrites the page's own /Rotate entry in the document
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
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 are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
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