Artículo técnico

Aplanar la rotación de páginas PDF sin romper cajas

HotPDF aplana la rotación de páginas PDF con THotPDF.FlattenLoadedPageRotation: el método envuelve el contenido de cada página rotada en una transformación cm horaria, reescribe cada page box que la página tenga de verdad, gira la geometría de anotaciones, las matrices de appearance, los destinos explícitos y la geometría estructurada por el mismo ángulo, y luego pone /Rotate a 0. La página se ve idéntica en un visor, pero su sistema de coordenadas ya está derecho. Eso importa en cuanto una herramienta aguas abajo, un RIP de impresión o su propio código de sellos ignora /Rotate y coloca cosas en user space crudo

El desencadenante típico es un escáner o una app de captura móvil que escribe páginas apaisadas como media vertical con /Rotate 90. Todos los visores las muestran bien, así que nadie se entera hasta que alguien estampa un número de página en la «esquina inferior derecha» y aterriza de lado a lo largo del borde izquierdo, o un paso de imposición que solo lee el /MediaBox maqueta un hueco vertical para una página apaisada. Aplanar suena a trabajo de matriz de una línea. En la práctica toca cinco page boxes, tres clases de geometría de anotaciones, los destinos de enlaces del documento y el árbol de estructura, y cada uno tiene su propia regla en ISO 32000-1

¿Hacia dónde gira /Rotate una página PDF?

/Rotate gira la página en sentido horario para visualización e impresión, en múltiplos de 90 grados (ISO 32000-1 §7.7.3.3, Tabla 30). A 90 grados el borde izquierdo del media pasa a ser la parte superior y el borde superior pasa al lado derecho, así que en un device space con y hacia abajo el mapeo es X = (y - Bottom) * Scale y Y = (x - Left) * Scale. A 270 grados el borde derecho pasa arriba. /Rotate es además uno de los solo cuatro atributos de página heredables, junto a /Resources, /MediaBox y /CropBox (§7.7.3.4), así que un diccionario de página sin /Rotate propio puede quedar girado por un ancestro /Pages. THotPDF.GetLoadedPageRotation recorre la cadena de /Parent y normaliza el resultado a 0–359, que es el valor que usted quiere, no la clave cruda de la página

El sentido es fácil de torcer de una manera que sobrevive al testing, y builds anteriores de HotPDF hicieron exactamente eso. La antigua matriz de página a dispositivo intercambiaba las componentes y para 90 y 270, lo que produce un reflejo por la diagonal en lugar de una rotación: la orientación de la matriz se voltea respecto al caso sin rotar. Ambos ángulos «parecían girados», el bitmap tenía el ancho y el alto intercambiados, y un round trip de página a vista y de vuelta devolvía el punto de partida, así que las comprobaciones de dimensiones y los tests de round trip pasaban todos. La única comprobación fiable es dónde acaba un marcador de esquina, comparado píxel a píxel contra un renderer de referencia. Como el modelo del visor, el backend de render SIMD y el mapeo de resaltados habían copiado la misma matriz, todos se corrigieron a la vez, y el código de aplanado usa ahora la misma convención horaria que el renderer

Cómo aplana HotPDF la rotación de página en Delphi: una página vertical guardada con /Rotate 90 se muestra en horario como una vista apaisada de 792 por 612, el mapeo a dispositivo X = (y - Bottom) * Scale, Y = (x - Left) * Scale mueve cada esquina, e intercambiar las componentes y de la matriz produce un reflejo que solo caza una comparación de marcadores de esquina
Los visores giran la página en horario para mostrarla mientras los bytes siguen verticales — GetLoadedPageRotation recorre primero la cadena /Parent, porque /Rotate es uno de los cuatro atributos de página heredables

Cómo reescribe FlattenLoadedPageRotation una página

FlattenLoadedPageRotation(PageRange, Info) procesa cada página de PageRange cuya rotación efectiva sea 90, 180 o 270, y devuelve el número de páginas que aplanó. Un PageRange vacío significa todas las páginas; si no, la cadena usa la sintaxis habitual 1-based '1-3,7', y un número de página fuera de rango lanza excepción en lugar de saltárselo. Los content streams originales jamás se recodifican. El método antepone a los /Contents de la página un stream nuevo que contiene q 0 -1 1 0 -Bottom Width+Left cm (para 90 grados), añade al final un stream con Q, y por último escribe un /Rotate 0 explícito en el diccionario de página para que un valor heredado en un nodo /Pages no pueda girar la página una segunda vez

var
  Pdf: THotPDF;
  Info: THPDFRotationFlattenInfo;
  Flattened: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
    begin
      // '' = todas las páginas; las páginas a 0 grados se escanean pero se dejan tranquilas
      Flattened := Pdf.FlattenLoadedPageRotation('', Info);
      Writeln(Format('Scanned %d, flattened %d pages', [Info.ScannedPageCount, Info.FlattenedPageCount]));
      Writeln(Format('Turned %d annotations, %d destinations, %d tagged geometry entries',
        [Info.TransformedAnnotationCount, Info.TransformedDestinationCount,
         Info.TransformedStructureGeometryCount]));
      if Flattened > 0 then
        Pdf.SaveLoadedDocument('scanned-batch-upright.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

El record THPDFRotationFlattenInfo merece registrarse en el log en lugar de tirarse. ScannedPageCount es el tamaño del rango, FlattenedPageCount iguala al valor de retorno, y los tres contadores Transformed... le dicen si el documento tenía enlaces, bookmarks o geometría estructurada apuntando a las páginas giradas. Un lote donde cada archivo reporta cero destinos está bien; un archivo PDF/UA etiquetado que reporta cero geometría estructurada cuando usted esperaba bounding boxes de figuras es una señal para inspeccionarlo a mano

¿Qué page boxes reescribe el aplanado, y en qué orden?

El aplanado reescribe solo las cajas que la página ya tiene, y lee todas las cajas antes de escribir ninguna. El orden importa por la cadena de valores por defecto: GetLoadedPageBox(PageIndex, pbCropBox, ...) devuelve el /MediaBox cuando la página no tiene /CropBox, y /BleedBox, /TrimBox y /ArtBox caen por defecto a la CropBox (§14.11.2). Una versión anterior sí leía, transformaba y escribía una caja cada vez. Reescribía primero el MediaBox, luego leía la «CropBox», recibía de vuelta el MediaBox ya girado, lo giraba una segunda vez y escribía una CropBox que la página jamás tuvo, lo que recortaba una página apaisada hasta dejarla cuadrada. Las reglas de herencia se parten igual: MediaBox y CropBox se buscan por la cadena de /Parent, mientras que Bleed, Trim y ArtBox solo cuentan si están en el propio diccionario de página, así que un /TrimBox perdido en un nodo /Pages se trata como ausente y jamás se copia a la página

procedure DumpPageGeometry(Pdf: THotPDF; PageIndex: Integer);
var
  L, B, R, T: Single;
begin
  Writeln('Effective /Rotate: ', Pdf.GetLoadedPageRotation(PageIndex));
  if Pdf.GetLoadedPageBox(PageIndex, pbMediaBox, L, B, R, T) then
    Writeln(Format('MediaBox [%g %g %g %g]', [L, B, R, T]));
  // True incluso sin clave /TrimBox: el valor recurre a CropBox, luego a MediaBox
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Precargado Letter; GetLoadedPageVisibleBox deja las salidas intactas si falla
  L := 0; B := 0; R := 612; T := 792;
  Pdf.GetLoadedPageVisibleBox(PageIndex, L, B, R, T);
  Writeln(Format('Visible  [%g %g %g %g]', [L, B, R, T]));
end;

Ejecute ese helper antes y después del aplanado y los números se explican solos. Para una página a 90 grados con MediaBox [0 0 612 792], el MediaBox aplanado pasa a ser [0 0 792 612]; cada caja reescrita se mapea por el mismo giro horario, relativo al origen del MediaBox original, así que el nuevo MediaBox empieza siempre en el origen y las demás cajas conservan su posición dentro. GetLoadedPageVisibleBox devuelve lo que los visores muestran y las impresoras imprimen, la CropBox recortada al MediaBox y normalizada de modo que Left sea menor que Right, y el renderer de HotPDF, el export SVG, el visor y la vía de impresión usan todos esa misma caja. Cuando necesite el tamaño de página que ve un humano, llame a GetLoadedPageVisibleBox en lugar de leer el /MediaBox

Por qué HotPDF lee todas las cajas de página antes de escribir ninguna durante FlattenLoadedPageRotation: BleedBox, TrimBox y ArtBox caen por defecto a la CropBox, que a su vez recurre al MediaBox, así que girar las cajas de una en una hizo que la CropBox leyera el MediaBox ya reescrito, y un segundo giro escribió una caja que la página jamás tuvo, recortando una página apaisada hasta dejarla cuadrada
La cadena de valores por defecto hace que la salida de una caja sea la entrada de otra — léalo todo primero, transforme contra el origen del MediaBox original, y escriba después

¿Por qué se rompen las anotaciones si solo gira /Rect?

Las anotaciones se rompen porque un appearance stream no se dibuja directamente en el /Rect. Bajo §12.5.5 el visor primero transforma el /BBox del formulario por su /Matrix, y luego escala y traslada el bounding box de ese resultado hasta el /Rect. Gire solo el /Rect y un sello de 200 × 40 acaba exprimido en un hueco de 40 × 200, ilegible y de lado. Por eso FlattenLoadedPageRotation multiplica por la derecha el giro horario de la página en cada /Matrix de appearance (para 90 grados, [0 -1 1 0 0 0] en la convención de vectores fila), a través de las appearances /N, /R y /D y de cada estado dentro de ellas. Un mismo appearance stream puede compartirse entre varias anotaciones o estados, así que cada stream se gira exactamente una vez por llamada. El único caso sin respuesta limpia es un stream compartido entre páginas con rotaciones distintas; sigue a la primera página que lo alcanza

Dos reglas más mantienen en su sitio los campos de formulario y las notas adhesivas. La entrada /MK /R de un widget (§12.5.6.19) es un ángulo antihorario, así que al ángulo horario de la página se le resta, módulo 360; sáltese eso y la siguiente regeneración de appearance dibuja el texto del campo en la dirección equivocada. Las anotaciones con el flag NoRotate (posición de bit 5, valor 16, §12.5.3) se quedan derechas en una página girada y pivotan sobre la esquina superior izquierda de su /Rect, así que el aplanado conserva su ancho, su alto y su aspecto derecho y solo mueve esa esquina a donde el giro la deja. Más allá de las anotaciones, el método también gira /QuadPoints, /Vertices, /L y /InkList, reescribe los destinos explícitos que nombran la página (puntos /XYZ, rectángulos /FitR, y /FitH / /FitV intercambiados a 90 y 270 grados, §12.3.2.2), y transforma geometría estructurada como las entradas /BBox de atributos de elementos de estructura cuyo /Pg es la página

Por qué se rompen las anotaciones cuando una página de HotPDF se aplana girando solo el /Rect: un sello de 200 por 40 se escala hasta un hueco de 40 por 200 y queda ilegible, así que FlattenLoadedPageRotation multiplica por la derecha el giro horario en cada /Matrix de appearance a través de /N, /R y /D, ajusta el /MK /R antihorario y pivota las anotaciones NoRotate sobre su esquina superior izquierda
El visor encaja el BBox transformado de la appearance dentro del /Rect, así que el stream en sí tiene que girar — un pase por appearance compartida, exactamente una vez por llamada

Qué no cubre el aplanado

El aplanado es una reescritura geométrica de los objetos propios de una página, y varias situaciones quedan fuera en silencio en lugar de a los gritos

  • Las páginas cuya rotación efectiva ya es 0, o cuyo MediaBox falta o tiene ancho o alto cero, se saltan sin error; compare el valor de retorno con el número de páginas que esperaba que cambiaran
  • Los Form XObjects referenciados desde los recursos de la página conservan su propio /BBox en form space, porque el cm exterior ya los gira; el barrido del árbol de estructura sigue solo /K y /A, así que jamás entra una segunda vez en los recursos o las anotaciones de la página
  • Los destinos se encuentran barriendo cada objeto indirecto una vez por página aplanada, así que un documento grande con cientos de páginas giradas paga ese paseo en cada una
  • El renderer de páginas de HotPDF no dibuja anotaciones, así que una comprobación visual de sellos girados necesita antes FlattenLoadedAnnotations
// Hornee las appearances en el contenido para que el renderer pueda mostrarlas,
// luego renderice la página 1 antes y después de quitar su /Rotate
Pdf.FlattenLoadedAnnotations('1');
Before := Pdf.RenderLoadedPageToBitmap(0, 96);
try
  Pdf.FlattenLoadedPageRotation('1', Info);
  After := Pdf.RenderLoadedPageToBitmap(0, 96);
  try
    Assert((Before.Width = After.Width) and (Before.Height = After.Height));
    // Compare aquí los píxeles del marcador de esquina, no solo las dimensiones
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

Para más trasfondo, el lado de anotaciones de esta historia continúa en sintetizar appearances de anotaciones antes de aplanarlas, el renderer detrás de la comparación de antes y después está cubierto en renderizar una página PDF cargada a un bitmap, y redacción y empalme N-up sobre PDFs cargados muestra la misma técnica de anexado a content streams de la que dependen el prefijo y el sufijo de rotación. HotPDF, incluidos FlattenLoadedPageRotation y los lectores de page boxes, está disponible para Delphi y C++Builder en la página del componente PDF HotPDF para Delphi