Artículo técnico

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

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 realmente tenga, gira la geometría de anotaciones, las matrices de apariencia, los destinos explícitos y la geometría de estructura etiquetada por el mismo ángulo, y después pone /Rotate en 0. La página se ve idéntica en un visor, pero su sistema de coordenadas ahora está derecho. Eso importa en el momento en que una herramienta aguas abajo, un print RIP o su propio código de estampado ignora /Rotate y coloca cosas en user space crudo

El disparador típico es un escáner o una app de captura móvil que escribe páginas landscape como medios portrait con /Rotate 90. Todos los visores las muestran bien, así que nadie nota nada hasta que alguien estampa un número de página en la «esquina inferior derecha» y cae de costado a lo largo del borde izquierdo, o un paso de imposición que solo lee /MediaBox arma un slot portrait para una página landscape. 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 links del documento y el árbol de estructura, y cada uno tiene su propia regla en ISO 32000-1

¿Hacia qué lado gira /Rotate una página PDF?

/Rotate gira la página en sentido horario para display 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 el tope y el borde superior pasa a ser el lado derecho, así que en un espacio de dispositivo con y hacia abajo el mapeo es X = (y - Bottom) * Scale y Y = (x - Left) * Scale. A 270 grados el borde derecho pasa a ser el tope. /Rotate es además uno de solo cuatro atributos de página heredables, junto con /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 camina la cadena de /Parent y normaliza el resultado a 0–359, que es el valor que usted quiere, no la clave cruda en la página

El sentido es fácil de errar de una manera que sobrevive al testing, y builds anteriores de HotPDF lo hicieron exactamente. La vieja matriz de página a dispositivo intercambiaba las componentes y para 90 y 270, lo que produce un reflejo sobre la diagonal en lugar de una rotación: la orientación de la matriz se voltea respecto del caso sin rotar. Ambos ángulos siguen «pareciendo rotados», el bitmap tiene el ancho y el alto intercambiados, y un round-trip de página a vista y de vuelta devuelve el punto de arranque, así que los chequeos de dimensiones y los tests de round-trip pasan todos. El único chequeo confiable es dónde termina un marcador de esquina, comparado píxel a píxel contra un renderer de referencia. Como el modelo de visor, el backend de render SIMD y el mapeo de highlights habían copiado la misma matriz, todos se corrigieron juntos, y el código de aplanado ahora usa la misma convención horaria que el renderer

Cómo HotPDF aplana la rotación de páginas en Delphi: una página portrait guardada con /Rotate 90 se muestra en horario como una vista landscape de 792 por 612, el mapeo de 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 portrait — GetLoadedPageRotation camina primero la cadena de /Parent, porque /Rotate es uno de los cuatro atributos de página heredables

Cómo FlattenLoadedPageRotation reescribe una página

FlattenLoadedPageRotation(PageRange, Info) procesa cada página de PageRange cuya rotación efectiva sea 90, 180 o 270, y devuelve la cantidad de páginas que aplanó. Un PageRange vacío significa todas las páginas; si no, el string usa la sintaxis de siempre base 1, '1-3,7', y un número de página fuera de rango lanza excepción en lugar de saltársela. Los content streams originales jamás se re-codifican. 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), agrega 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 vale la pena mandarlo al log en vez de descartarlo. 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 links, bookmarks o geometría etiquetada 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 de estructura 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 cualquiera. El orden importa por la cadena de defaults: GetLoadedPageBox(PageIndex, pbCropBox, ...) devuelve el /MediaBox cuando la página no tiene /CropBox, y /BleedBox, /TrimBox y /ArtBox caen por defecto al CropBox (§14.11.2). Una versión anterior leía, transformaba y escribía una caja por vez. Reescribía primero el MediaBox, después leía la «CropBox», recibía de vuelta el MediaBox ya girado, lo giraba una segunda vez y escribía un CropBox que la página nunca tuvo, lo que recortaba una página landscape a un cuadrado. Las reglas de herencia se parten igual: MediaBox y CropBox se buscan por la cadena de /Parent, mientras que Bleed, Trim y ArtBox cuentan solo 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 aun sin clave /TrimBox: el valor cae al CropBox y después al MediaBox
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Precarga 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;

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

Por qué HotPDF lee todos los page boxes antes de escribir cualquiera durante FlattenLoadedPageRotation: BleedBox, TrimBox y ArtBox caen por defecto al CropBox, que a su vez cae al MediaBox, así que girar las cajas de a una hizo que el CropBox leyera el MediaBox ya reescrito, y un segundo giro escribió una caja que la página nunca tuvo, recortando una página landscape a un cuadrado
La cadena de defaults significa que la salida de una caja es la entrada de otra — lea todo primero, transforme contra el origen del MediaBox original, y después escriba

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

Las anotaciones se rompen porque un appearance stream no se dibuja directo en /Rect. Bajo §12.5.5 el visor primero transforma el /BBox del form por su /Matrix, después escala y traslada el bounding box de ese resultado hasta /Rect. Gire solo /Rect y un sello de 200 × 40 queda apretado en un slot de 40 × 200, ilegible y de costado. FlattenLoadedPageRotation por eso multiplica por la derecha el giro horario de la página sobre cada /Matrix de apariencia (para 90 grados, [0 -1 1 0 0 0] en la convención de vectores fila), a través de las apariencias /N, /R y /D y cada estado dentro de ellas. Un 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 a los form fields y a las sticky notes. 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 próxima regeneración de apariencia 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 rotada y pivotean alrededor de la esquina superior izquierda de su /Rect, así que el aplanado conserva su ancho, alto y apariencia derecha 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 etiquetada 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 /Rect: un sello de 200 por 40 se escala a un slot de 40 por 200 y se vuelve ilegible, así que FlattenLoadedPageRotation multiplica por la derecha el giro horario sobre cada /Matrix de apariencia a través de /N, /R y /D, ajusta el /MK /R antihorario y pivotea las anotaciones NoRotate sobre su esquina superior izquierda
El visor calza el BBox transformado de la apariencia dentro de /Rect, así que el stream en sí tiene que girar — una pasada por apariencia 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 afuera 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 la cantidad de páginas que esperaba cambiar
  • Los form XObjects referenciados desde los recursos de la página conservan su /BBox en espacio de form, porque el cm externo ya los gira; el escaneo del árbol de estructura sigue solo /K y /A así que nunca vuelve a meterse en los recursos o anotaciones de la página
  • Los destinos se encuentran escaneando cada objeto indirecto una vez por página aplanada, así que un documento grande con cientos de páginas rotadas paga ese recorrido por cada una
  • El renderer de páginas de HotPDF no dibuja anotaciones, así que un chequeo visual de sellos girados necesita primero FlattenLoadedAnnotations
// Hornee las apariencias en el contenido para que el renderer pueda mostrarlas,
// después renderice la página 1 antes y después de quitarle 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 sigue en sintetizar apariencias 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 agregado 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 HotPDF Delphi PDF