Artículo técnico

Bugs de doble rotación y zoom ajustado de PDFium en Delphi

La función FPDF_RenderPageBitmap del componente PDFium acepta un argumento rotate que PDFium siempre suma encima de cualquier rotación que la página ya lleve en su propia entrada /Rotate, así que leer la rotación almacenada de una página y volver a entregar ese mismo valor a la llamada de renderizado rota la página dos veces. El mismo error idéntico aparece en la aritmética de zoom ajustado: dimensionar una miniatura a partir del ancho y alto sin rotar de la página produce la relación de aspecto equivocada cada vez que /Rotate es 90 o 270 grados, porque el mapa de bits renderizado sale con el ancho y el alto intercambiados

El fallo es fácil de detectar una vez que se sabe qué buscar, y fácil de pasar por alto hasta entonces. Llega un lote de facturas escaneadas con una mezcla de originales en vertical y horizontal, alguien endereza la mitad de ellas con una rotación de 90 grados en Acrobat antes de archivarlas, y la tira de miniaturas en un visor Delphi construido sobre PDFium renderiza esas páginas en particular de lado, al revés, o comprimidas en una caja con forma para la orientación equivocada. Nada lanza una excepción. Nada registra un error. Los píxeles simplemente están mal, y solo para el subconjunto de páginas que alguien rotó después del hecho —exactamente el tipo de bug que sobrevive a una pasada de control de calidad completa contra un PDF de prueba sin rotar y luego aparece en producción en la página 47 de uno real

¿Por qué PDFium rota la página dos veces?

PDFium aplica automáticamente el propio valor /Rotate de una página cada vez que renderiza un mapa de bits, sin importar qué se le pase al renderizador. El parámetro rotate de FPDF_RenderPageBitmap, expuesto en PDFiumPas como los valores TRotation ro0, ro90, ro180 y ro270 en TPdf.RenderPage, TPdf.RenderTile y TPdf.RenderPageThumbnail, no establece el ángulo en el que debería terminar una página; el parámetro rotate establece cuánta rotación extra apilar encima de lo que sea que ya especifique el diccionario de página, que es por qué cada uno de esos métodos lo tiene predeterminado en ro0

TPdf.PageRotation lee ese mismo valor /Rotate mediante FPDFPage_GetRotation, y el código de aplicación a menudo lo necesita por razones que no tienen nada que ver con el renderizado, como decidir cómo maquetar una anotación en el espacio de página. La trampa es una sola línea: pasar PageRotation al argumento Rotation de RenderPage, esperando que la llamada normalice la página hacia arriba. Una página ya guardada con /Rotate 90 se muestra correctamente, rotada, en cualquier visor conforme, PDFium incluido; agregue ro90 de nuevo encima de eso y la página gira a 180 grados en lugar de los 90 previstos, mientras que una página sin ninguna rotación en absoluto recibe un giro de cuarto de vuelta no deseado sin razón

// 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 qué es realmente el parámetro Rotation?

El parámetro Rotation se gana su lugar en la API para un trabajo genuinamente distinto: agregar una rotación de solo vista que no tiene nada que ver con la orientación almacenada de una página, del tipo que aplica un botón de barra de herramientas de rotar vista sin tocar el archivo subyacente. TPdfView mantiene los dos conceptos como dos propiedades separadas exactamente por esta razón. TPdfView.PageRotation refleja el propio /Rotate de la página y, mediante FPDFPage_SetRotation, puede escribir un nuevo valor de vuelta en el documento; TPdfView.Rotation es una propiedad transitoria, de solo vista, que por defecto es ro0 y nunca toca el archivo. Leer la primera propiedad y escribirla en la segunda es todo el bug en una sola 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;

¿Por qué se rompe de la misma manera el dimensionamiento con zoom ajustado?

El dimensionamiento con zoom ajustado se rompe por una razón especular: el cálculo empieza desde el par de números equivocado en lugar del ángulo equivocado. Una forma típica de dimensionar una caja de miniatura le pide a PDFium el ancho y alto de una página, compara esa relación de aspecto contra la caja disponible, y calcula el rectángulo más grande que cabe dentro de ella —lo cual funciona limpiamente para una página sin rotar. El mismo cálculo falla silenciosamente para una página /Rotate 90 o /Rotate 270 cuando el ancho y el alto vienen de una llamada que reporta el tamaño intrínseco, sin rotar, de la página: una página A4 vertical que lleva /Rotate 90 sigue reportando aproximadamente 595 por 842 puntos, aunque PDFium la renderiza, correctamente, en aproximadamente 842 por 595 una vez que la rotación surte efecto, y una caja ajustada calculada a partir del par sin rotar termina con forma para una orientación completamente equivocada

FPDF_GetPageSizeByIndex es un ejemplo concreto de una llamada que reporta ese tamaño intrínseco, sin rotar, por diseño, lo que la hace conveniente para escanear dimensiones de página sin cargar cada una y riesgosa para la aritmética de zoom ajustado que olvida tenerlo en cuenta. La corrección se sigue directamente de nombrar el problema: comprobar la rotación de la página antes de hacer la aritmética de ajuste, intercambiar ancho y alto cada vez que esa rotación es de 90 o 270 grados, calcular la caja de ajuste a partir del par intercambiado, y todavía pasar ro0 a la llamada de renderizado real, porque PDFium sigue siendo quien aplica la rotación real

Obtener miniaturas correctas sin reinventar la aritmética de ajuste

TPdf.RenderPageThumbnail ya lleva incorporada esta corrección, así que la ruta más corta hacia una miniatura correcta es llamarla en lugar de rearmar a mano la lógica de ajuste y rotación. Dado un índice de página basado en 1 y un ancho y alto máximos, RenderPageThumbnail calcula una caja de ajuste, la corrige internamente para un /Rotate de 90 o 270, y devuelve un mapa de bits propiedad de quien llama sin perturbar la página actual del documento ni disparar un evento OnPageChange —lo cual importa para una tira de miniaturas construida junto a un visor en vivo sobre la misma instancia 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 la pena conservar el helper FitBox de todos modos, porque RenderPageThumbnail solo cubre el caso de un único mapa de bits. Una grilla de miniaturas personalizada, una tira de vista previa de impresión, o un diálogo selector de página que maqueta varias páginas contra cajas independientes necesita la misma aritmética de ajuste consciente de rotación sin necesariamente querer un mapa de bits nuevo para cada mosaico, y los propios modos de zoom ajustar-a-página y ajustar-al-ancho de TPdfView se apoyan internamente en la misma idea, eligiendo entre el ancho y el alto de una página para el cálculo de proporción de zoom según la rotación actual de la vista antes de compararlo contra el área de cliente disponible. Si el rendimiento de zoom y desplazamiento en ese tipo de visor es el siguiente problema en la lista, el artículo complementario sobre caché de renderizado y zoom fluido en un visor Delphi basado en PDFium retoma exactamente donde deja el dimensionamiento correcto

Detectar una doble rotación antes que un cliente

Una doble rotación tiene una firma visual confiable: una página que fue rotada 90 grados a la entrada sale viéndose rotada 180 en relación con el resto del documento, no 90, porque el ro90 extra se apiló encima del propio ro90 de la página en lugar de reemplazarlo. Un fixture de prueba construido solo con páginas /Rotate 0 nunca atrapará esto, ya que sumar ro0 a ro0 sigue siendo ro0 y el bug permanece invisible; un fixture necesita al menos una página guardada con /Rotate 90 y una con /Rotate 270 antes de que se pueda confiar en una ruta de código de miniatura o zoom ajustado

El pipeline básico de página a mapa de bits cubierto en renderizar páginas de PDF a JPEG con el componente PDFium ya renderiza páginas rotadas correctamente sin ningún código de caso especial, precisamente porque deja Rotation en su valor predeterminado ro0 y deja que PDFium aplique /Rotate por sí mismo. El bug de doble rotación solo aparece una vez que el código de aplicación empieza a leer PageRotation de vuelta y a entregarlo en algún lugar donde no pertenece

Las llamadas de renderizado conscientes de rotación y el dimensionamiento de miniaturas descritos aquí son parte del componente PDFium para Delphi y C++Builder, junto con el resto de las API de renderizado, visualización, y extracción de texto construidas sobre las mismas clases TPdf y TPdfView