Artigo Técnico

Achatar rotação de página PDF sem quebrar boxes no Delphi

O HotPDF achata a rotação de página de PDF com THotPDF.FlattenLoadedPageRotation: o método embrulha o conteúdo de cada página rotacionada numa transformação cm horária, reescreve cada page box que a página de fato tem, gira a geometria de anotações, matrizes de appearance, destinos explícitos e geometria de estrutura taggada pelo mesmo ângulo, e então define /Rotate como 0. A página fica idêntica num viewer, mas o sistema de coordenadas dela agora é ereto. Isso importa no momento em que uma ferramenta downstream, um print RIP ou o seu próprio código de stamping ignora o /Rotate e coloca coisas no user space cru

O gatilho típico é um scanner ou um app de captura mobile que grava páginas landscape como media portrait com /Rotate 90. Todo viewer as mostra corretamente, então ninguém nota até alguém estampar um número de página no "canto inferior direito" e ele cair de lado ao longo da borda esquerda, ou um passo de imposition que só lê /MediaBox montar um slot portrait para uma página landscape. Achatar soa como um trabalho de matriz de uma linha só. Na prática toca cinco page boxes, três tipos de geometria de anotação, os alvos de link do documento e a structure tree, e cada um deles tem a própria regra na ISO 32000-1

Para que lado o /Rotate gira uma página de PDF?

O /Rotate gira a página no sentido horário para exibição e impressão, em múltiplos de 90 graus (ISO 32000-1 §7.7.3.3, Tabela 30). A 90 graus a borda esquerda do media vira o topo e a borda de cima vira o lado direito, então num device space com y para baixo o mapeamento é X = (y - Bottom) * Scale e Y = (x - Left) * Scale. A 270 graus a borda direita vira o topo. O /Rotate também é um dos apenas quatro atributos de página herdáveis, junto com /Resources, /MediaBox e /CropBox (§7.7.3.4), então um dicionário de página sem /Rotate próprio ainda pode ser girado por um ancestral /Pages. O THotPDF.GetLoadedPageRotation caminha pela cadeia de /Parent e normaliza o resultado para 0–359, que é o valor que você quer, não a chave crua na página

O sentido é fácil de errar de um jeito que sobrevive aos testes, e builds anteriores do HotPDF fizeram exatamente isso. A antiga matriz de página para device trocava os componentes y para 90 e 270, o que produz uma reflexão pela diagonal em vez de uma rotação: a orientação da matriz vira em relação ao caso sem rotação. Os dois ângulos ainda "pareciam girados", o bitmap tem largura e altura trocadas, e um round trip de página para view e de volta retorna o ponto de partida, então checagens de dimensão e testes de round trip todos passam. A única checagem confiável é onde um marcador de canto acaba, comparado pixel por pixel contra um renderer de referência. Como o modelo de viewer, o backend de render SIMD e o mapeamento de highlight tinham copiado a mesma matriz, todos foram corrigidos juntos, e o código de achatamento agora usa a mesma convenção horária do renderer

Como o HotPDF achata a rotação de página em Delphi: uma página portrait armazenada com /Rotate 90 exibe horariamente como uma view landscape de 792 por 612, o mapeamento de device X = (y - Bottom) * Scale, Y = (x - Left) * Scale move cada canto, e trocar os componentes y da matriz produz uma reflexão que só uma comparação de marcador de canto pega
Viewers giram a página no sentido horário para exibição enquanto os bytes continuam portrait — o GetLoadedPageRotation caminha pela cadeia de /Parent primeiro, porque o /Rotate é um dos quatro atributos de página herdáveis

Como o FlattenLoadedPageRotation reescreve uma página

O FlattenLoadedPageRotation(PageRange, Info) processa toda página em PageRange cuja rotação efetiva é 90, 180 ou 270, e retorna o número de páginas que achatou. Um PageRange vazio significa todas as páginas; caso contrário a string usa a sintaxe usual 1-based '1-3,7', e um número de página fora do intervalo levanta exceção em vez de ser pulado. Os content streams originais nunca são recodificados. O método precede um stream novo contendo q 0 -1 1 0 -Bottom Width+Left cm (para 90 graus) ao /Contents da página, anexa um stream contendo Q, e por fim grava um /Rotate 0 explícito no dicionário de página para que um valor herdado num nó /Pages não possa girar a página uma 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 as páginas; páginas a 0 graus são varridas mas ficam como estão
      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;

O record THPDFRotationFlattenInfo merece ir para o log em vez de ser descartado. O ScannedPageCount é o tamanho da faixa, o FlattenedPageCount iguala o valor de retorno, e os três contadores Transformed... dizem se o documento tinha links, bookmarks ou geometria taggada apontando para as páginas giradas. Um lote em que todo arquivo reporta zero destinos está ok; um arquivo PDF/UA taggado que reporta zero geometria de estrutura quando você esperava bounding boxes de figure é um sinal para inspecioná-lo à mão

Quais page boxes o achatamento reescreve, e em que ordem?

O achatamento reescreve só os boxes que a página já tem, e lê todo box antes de gravar qualquer um. A ordem importa por causa da cadeia de defaults: o GetLoadedPageBox(PageIndex, pbCropBox, ...) retorna a /MediaBox quando a página não tem /CropBox, e /BleedBox, /TrimBox e /ArtBox caem no CropBox por default (§14.11.2). Uma versão anterior lia, transformava e gravava um box por vez. Ela reescrevia a MediaBox primeiro, depois lia a "CropBox", recebia a MediaBox já girada de volta, girava uma segunda vez e gravava uma CropBox que a página nunca teve, o que cortava uma página landscape até virar um quadrado. As regras de herança se dividem do mesmo jeito: MediaBox e CropBox são buscadas ao longo da cadeia de /Parent, enquanto Bleed, Trim e ArtBox contam só se sentam no dicionário de página em si, então um /TrimBox perdido num nó /Pages é tratado como ausente e nunca é copiado para a 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 mesmo sem chave /TrimBox: o valor cai para CropBox, depois MediaBox
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Letter pré-ajustado; GetLoadedPageVisibleBox deixa as saídas intocadas em falha
  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;

Rode esse helper antes e depois do achatamento e os números se explicam sozinhos. Para uma página de 90 graus com MediaBox [0 0 612 792], a MediaBox achatada vira [0 0 792 612]; todo box reescrito é mapeado pela mesma volta horária, relativo à origem da MediaBox original, então a MediaBox nova sempre começa na origem e os outros boxes mantêm a posição deles dentro dela. O GetLoadedPageVisibleBox retorna o que viewers exibem e impressoras imprimem, a CropBox cortada pela MediaBox e normalizada para Left ser menor que Right, e o renderer, o export SVG, o viewer e o caminho de impressão do HotPDF todos usam esse mesmo box. Quando você precisa do tamanho de página que um humano vê, chame o GetLoadedPageVisibleBox em vez de ler a /MediaBox

Por que o HotPDF lê todo page box antes de gravar qualquer um durante o FlattenLoadedPageRotation: BleedBox, TrimBox e ArtBox caem no CropBox por default, que por sua vez cai na MediaBox, então girar os boxes um por vez fez a CropBox ler a MediaBox já reescrita e uma segunda volta gravou um box que a página nunca teve, cortando uma página landscape até um quadrado
A cadeia de defaults significa que a saída de um box é a entrada de outro — leia tudo primeiro, transforme contra a origem da MediaBox original, depois grave

Por que anotações quebram quando você só gira o /Rect?

Anotações quebram porque um appearance stream não é desenhado direto no /Rect. Sob a §12.5.5 o viewer primeiro transforma a /BBox do form pela /Matrix dele, depois escala e translada o bounding box desse resultado para dentro do /Rect. Gire só o /Rect e um carimbo de 200 × 40 é espremido num slot de 40 × 200, ilegível e deitado. O FlattenLoadedPageRotation portanto multiplica à direita a volta horária da página na /Matrix de cada appearance (para 90 graus, [0 -1 1 0 0 0] na convenção de row-vector), pelas appearances /N, /R e /D e por todo estado dentro delas. Um appearance stream pode ser compartilhado por várias anotações ou estados, então cada stream é girado exatamente uma vez por chamada. O único caso sem resposta limpa é um stream compartilhado entre páginas com rotações diferentes; ele segue a primeira página que o alcança

Duas regras a mais mantêm form fields e sticky notes no lugar. A entrada /MK /R de um widget (§12.5.6.19) é um ângulo anti-horário, então o ângulo horário da página é subtraído dele, módulo 360; pule isso e a próxima regeneração de appearance desenha o texto do campo na direção errada. Anotações com a flag NoRotate (posição de bit 5, valor 16, §12.5.3) ficam eretas numa página girada e pivotam em volta do canto superior esquerdo do /Rect delas, então o achatamento mantém a largura, a altura e a aparência ereta delas e só move esse canto para onde a volta o põe. Além de anotações, o método também gira /QuadPoints, /Vertices, /L e /InkList, reescreve destinos explícitos que nomeiam a página (pontos /XYZ, retângulos /FitR, e /FitH / /FitV trocados a 90 e 270 graus, §12.3.2.2), e transforma geometria taggada como entradas de /BBox de atributos para elementos de estrutura cujo /Pg é a página

Por que anotações quebram quando uma página do HotPDF é achatada girando só o /Rect: um carimbo de 200 por 40 é escalado para um slot de 40 por 200 e fica ilegível, então o FlattenLoadedPageRotation multiplica à direita a volta horária na /Matrix de cada appearance por /N, /R e /D, ajusta o /MK /R anti-horário e pivota anotações NoRotate no canto superior esquerdo delas
O viewer encaixa a BBox transformada da appearance no /Rect, então o stream em si precisa girar — uma passada por appearance compartilhada, exatamente uma vez por chamada

O que o achatamento não cobre?

O achatamento é uma reescrita geométrica dos objetos próprios de uma página, e várias situações ficam fora dele em silêncio em vez de com barulho

  • Páginas cuja rotação efetiva já é 0, ou cuja MediaBox falta ou tem largura ou altura zero, são puladas sem erro; compare o valor de retorno com o número de páginas que você esperava mudar
  • Form XObjects referenciados dos recursos da página mantêm a própria /BBox no espaço de form, porque o cm externo já os gira; a varredura da structure tree segue só /K e /A então nunca entra nos recursos da página ou nas anotações uma segunda vez
  • Destinos são encontrados varrendo todo objeto indireto uma vez por página achatada, então um documento grande com centenas de páginas giradas paga essa caminhada em cada uma
  • O page renderer do HotPDF não desenha anotações, então uma checagem visual de carimbos girados precisa do FlattenLoadedAnnotations primeiro
// Asse as appearances no conteúdo para o renderer poder mostrá-las,
// depois renderize a página 1 antes e depois de remover o /Rotate dela
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 aqui os pixels do marcador de canto, não só as dimensões
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

Para contexto mais fundo, o lado de anotações desta história continua em sintetizar appearances de anotações antes de achatá-las, o renderer por trás da comparação de antes e depois é coberto em renderizar uma página de PDF carregada para um bitmap, e redaction e costura N-up em PDFs carregados mostra a mesma técnica de anexar content stream de que o prefixo e o sufixo de rotação dependem. O HotPDF, incluindo o FlattenLoadedPageRotation e os leitores de page box, está disponível para Delphi e C++Builder na página do componente HotPDF Delphi PDF