Artigo Técnico

Achatar rotação de páginas PDF sem partir caixas no Delphi

O HotPDF achata a rotação de páginas PDF com o THotPDF.FlattenLoadedPageRotation: o método embrulha o conteúdo de cada página rodada numa transformação cm no sentido horário, reescreve todas as caixas de página que a página efetivamente tem, roda a geometria de anotações, matrizes de aparência, destinos explícitos e geometria de estrutura etiquetada pelo mesmo ângulo, e depois põe o /Rotate em 0. A página parece idêntica num visualizador, mas o seu sistema de coordenadas agora está direito. Isso interessa no momento em que uma ferramenta a jusante, um RIP de impressão ou o seu próprio código de carimbos ignora o /Rotate e põe coisas no user space cru

O despoletar típico é um scanner ou uma app de captura móvel que escreve páginas paisagem como mídia retrato com /Rotate 90. Todos os visualizadores mostram-nas corretamente, por isso ninguém repara até alguém carimbar um número de página no «canto inferior direito» e ele aterrar de lado ao longo da margem esquerda, ou um passo de imposição que só lê o /MediaBox dispôr um lugar retrato para uma página paisagem. Achatar soa a um trabalho de matriz de uma linha. Na prática toca em cinco caixas de página, três tipos de geometria de anotações, os alvos de ligações do documento e a árvore de estrutura, e cada um desses tem a sua própria regra na ISO 32000-1

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

O /Rotate roda a página no sentido horário para ecrã e impressão, em múltiplos de 90 graus (ISO 32000-1 §7.7.3.3, Tabela 30). A 90 graus a margem esquerda da mídia torna-se o topo e a margem de topo torna-se o lado direito, por isso num device space com y para baixo o mapeamento é X = (y - Bottom) * Scale e Y = (x - Left) * Scale. A 270 graus a margem direita torna-se 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), por isso um dicionário de página sem /Rotate próprio pode ainda ser rodado por um ancestral /Pages. O THotPDF.GetLoadedPageRotation percorre a cadeia de /Parent e normaliza o resultado em 0-359, que é o valor que quer, e não a chave crua na página

A direção é fácil de errar de uma forma que sobrevive aos testes, e builds anteriores do HotPDF fizeram precisamente isso. A antiga matriz de página para dispositivo trocava as componentes y para 90 e 270, o que produz uma reflexão ao longo da diagonal em vez de uma rotação: a orientação da matriz vira relativamente ao caso sem rotação. Ambos os ângulos ainda «pareciam rodados», o bitmap tem a largura e a altura trocadas, e um round trip de página para vista e de volta devolve o ponto de partida, por isso verificações de dimensões e testes de round trip passavam todos. A única verificação fiável é onde acaba um marcador de canto, comparado pixel a pixel contra um renderizador de referência. Como o modelo do visualizador, o backend de renderização SIMD e o mapeamento de realces tinham copiado a mesma matriz, todos foram corrigidos em conjunto, e o código de achatamento agora usa a mesma convenção horária que o renderizador

Como o HotPDF achata a rotação de páginas no Delphi: uma página retrato guardada com /Rotate 90 mostra-se em horário como uma vista paisagem de 792 por 612, o mapeamento de dispositivo X = (y - Bottom) * Scale, Y = (x - Left) * Scale move cada canto, e trocar as componentes y da matriz produz uma reflexão que só uma comparação de marcador de canto apanha
Os visualizadores rodam a página em horário para exibição enquanto os bytes continuam retrato — o GetLoadedPageRotation percorre primeiro a cadeia de /Parent, porque o /Rotate é um dos quatro atributos de página herdáveis

Como o FlattenLoadedPageRotation reescreve uma página

O FlattenLoadedPageRotation(PageRange, Info) processa todas as páginas em PageRange cuja rotação efetiva é 90, 180 ou 270, e devolve o número de páginas que achatou. Um PageRange vazio significa todas as páginas; caso contrário a string usa a sintaxe habitual baseada em um '1-3,7', e um número de página fora do intervalo levanta uma exceção em vez de ser saltado. Os content streams originais nunca são recodificados. O método antepõe um stream novo contendo q 0 -1 1 0 -Bottom Width+Left cm (para 90 graus) ao /Contents da página, acrescenta um stream contendo Q, e por fim escreve um /Rotate 0 explícito no dicionário da página para que um valor herdado num nó /Pages não possa rodar 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 verificadas e 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 vale a pena registar em log em vez de deitar fora. O ScannedPageCount é o tamanho do intervalo, o FlattenedPageCount é igual ao valor de retorno, e os três contadores Transformed... dizem-lhe se o documento tinha ligações, bookmarks ou geometria etiquetada a apontar para as páginas rodadas. Um lote em que todos os ficheiros reportam zero destinos está bem; um ficheiro PDF/UA etiquetado que reporte zero geometria de estrutura quando esperava caixas delimitadoras de figuras é um sinal para o inspecionar à mão

Que caixas de página o achatamento reescreve, e em que ordem?

O achatamento reescreve só as caixas que a página já tem, e lê todas as caixas antes de escrever qualquer uma. A ordem interessa por causa da cadeia de predefinições: o GetLoadedPageBox(PageIndex, pbCropBox, ...) devolve o /MediaBox quando a página não tem /CropBox, e /BleedBox, /TrimBox e /ArtBox têm o CropBox como predefinição (§14.11.2). Uma versão anterior lia, transformava e escrevia uma caixa de cada vez. Reescrevia primeiro o MediaBox, depois lia o «CropBox», recebia de volta o MediaBox já rodado, rodava-o uma segunda vez e escrevia um CropBox que a página nunca teve, o que recortava uma página paisagem para um quadrado. As regras de herança dividem-se da mesma forma: MediaBox e CropBox são procurados ao longo da cadeia de /Parent, enquanto Bleed, Trim e ArtBox só contam se estiverem no dicionário da página própria, por isso 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 recua para o CropBox, depois o MediaBox
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Letter predefinido; o GetLoadedPageVisibleBox deixa os outputs intactos 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;

Corra esse helper antes e depois do achatamento e os números explicam-se. Para uma página de 90 graus com MediaBox [0 0 612 792], o MediaBox achatado torna-se [0 0 792 612]; todas as caixas reescritas são mapeadas pela mesma volta horária, relativa à origem do MediaBox original, por isso o novo MediaBox começa sempre na origem e as outras caixas mantêm a posição dentro dele. O GetLoadedPageVisibleBox devolve o que os visualizadores mostram e as impressoras imprimem, o CropBox recortado ao MediaBox e normalizado de modo a que Left seja menor que Right, e o renderizador, a exportação SVG, o visualizador e o caminho de impressão do HotPDF usam todos essa mesma caixa. Quando precisa do tamanho de página que um humano vê, chame o GetLoadedPageVisibleBox em vez de ler o /MediaBox

Porque é que o HotPDF lê todas as caixas de página antes de escrever qualquer uma durante o FlattenLoadedPageRotation: BleedBox, TrimBox e ArtBox têm o CropBox como predefinição, que por sua vez recua para o MediaBox, por isso rodar caixas uma a cada vez fazia o CropBox ler o MediaBox já reescrito e uma segunda volta escrevia uma caixa que a página nunca teve, recortando uma página paisagem para um quadrado
A cadeia de predefinições significa que o output de uma caixa é o input de outra — leia tudo primeiro, transforme contra a origem do MediaBox original, depois escreva

Porque é que as anotações partem quando só roda o /Rect?

As anotações partem porque um appearance stream não é desenhado diretamente no /Rect. Sob a §12.5.5 o visualizador primeiro transforma o /BBox do formulário pela sua /Matrix, depois escala e translada a caixa delimitadora desse resultado para dentro do /Rect. Rode só o /Rect e um carimbo de 200 × 40 fica espremido num lugar de 40 × 200, ilegível e de lado. O FlattenLoadedPageRotation por isso multiplica à direita a volta horária da página na /Matrix de cada aparência (para 90 graus, [0 -1 1 0 0 0] na convenção de vetores linha), pelas aparências /N, /R e /D e por todos os estados dentro delas. Um appearance stream pode ser partilhado por várias anotações ou estados, por isso cada stream é rodado exatamente uma vez por chamada. O único caso sem resposta limpa é um stream partilhado por páginas com rotações diferentes; segue a primeira página que o alcança

Mais duas regras mantêm campos de formulário e notas no sítio. A entrada /MK /R de um widget (§12.5.6.19) é um ângulo no sentido antihorário, por isso o ângulo horário da página é subtraído, módulo 360; salte isso e a próxima regeneração de aparência 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 direitos numa página rodada e rodam em volta do canto superior esquerdo do seu /Rect, por isso o achatamento mantém a largura, a altura e a aparência direita delas e só move esse canto para onde a volta o põe. Para além das anotações, o método também roda /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 etiquetada como entradas /BBox de atributos para elementos de estrutura cujo /Pg é a página

Porque é que as anotações partem quando uma página do HotPDF é achatada rodando só o /Rect: um carimbo de 200 por 40 é escalado para um lugar de 40 por 200 e fica ilegível, por isso o FlattenLoadedPageRotation multiplica à direita a volta horária na /Matrix de cada aparência ao longo de /N, /R e /D, ajusta o /MK /R antihorário e roda as anotações NoRotate em volta do canto superior esquerdo
O visualizador encaixa o BBox transformado da aparência no /Rect, por isso o próprio stream tem de rodar — um passe por aparência partilhada, exatamente uma vez por chamada

O que é 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 e não com barulho

  • Páginas cuja rotação efetiva já é 0, ou cujo MediaBox falta ou tem largura ou altura zero, são saltadas sem erro; compare o valor de retorno com o número de páginas que esperava mudar
  • Os Form XObjects referenciados dos recursos da página mantêm o seu próprio /BBox no espaço do formulário, porque o cm exterior já os roda; o percurso da árvore de estrutura segue apenas /K e /A por isso nunca entra uma segunda vez nos recursos ou anotações da página
  • Os destinos são encontrados percorrendo cada objeto indireto uma vez por página achatada, por isso um documento grande com centenas de páginas rodadas paga esse percurso em cada uma
  • O renderizador de páginas do HotPDF não desenha anotações, por isso uma verificação visual de carimbos rodados precisa primeiro de FlattenLoadedAnnotations
// Asse as aparências no conteúdo para o renderizador as conseguir mostrar,
// depois renderize a página 1 antes e depois de lhe remover o /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 aqui os píxeis do marcador de canto, e não só as dimensões
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

Para mais contexto, o lado das anotações desta história continua em sintetizar aparências de anotações antes de as achatar, o renderizador por trás da comparação antes e depois está coberto em renderizar uma página PDF carregada para um bitmap, e redação e imposição N-up em PDFs carregados mostra a mesma técnica de acréscimo a content streams de que o prefixo e o sufixo de rotação dependem. O HotPDF, incluindo o FlattenLoadedPageRotation e os leitores de caixas de página, está disponível para Delphi e C++Builder na página do componente PDF Delphi HotPDF