Technical Article

Erros de ordenação de páginas PDF no HotPDF: estrutura física vs lógica

O sintoma manifestou-se num utilitário de cópia de páginas construído sobre o Componente HotPDF: ao solicitar a página 1 de um documento de três páginas, este produzia sistematicamente a página 2. A verificação da lógica de indexação não revelou qualquer falha. A chamada utilizava um índice lógico baseado em 0, a aritmética estava correta e as condições de limite estavam corretas. No entanto, a página errada era devolvida em todas as execuções.

O erro não estava de todo no código de cópia. Residias na forma como o HotPDF construía a sua matriz interna de páginas ao carregar o ficheiro.

Concept of PDF page order: difference between physical order and logical order
Ordenação de páginas PDF: a matriz /Kids na árvore Pages define a sequência lógica, independentemente de como os objetos estão numerados ou armazenados no ficheiro

Duas ordenações, uma fonte de confusão

Um ficheiro PDF é uma coleção de objetos indiretos, cada um identificado por um número de objeto. A estrutura do ficheiro não impõe que esses números reflitam a ordem de leitura. O objeto 1 pode conter a página 2; o objeto 20 pode conter a página 1. O que realmente define a ordem de leitura é a árvore de páginas: uma hierarquia de dicionários /Pages cujas matrizes /Kids listam as referências das páginas na sequência em que o visualizador as deve apresentar (ISO 32000-1 §7.7.3).

O documento que desencadeou o erro apresentava a seguinte estrutura de árvore de páginas:

{ Pages tree root, object 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

O ficheiro listava o objeto 1 e o objeto 4 antes do objeto 20 no fluxo de bytes. Qualquer analisador (parser) que percorresse os objetos indiretos na ordem do ficheiro e os registasse numa matriz PageArr à medida que encontrasse dicionários do tipo página acabaria por colocar o objeto 1 no índice 0, o objeto 4 no índice 1 e o objeto 20 no índice 2. A página lógica 1 ficaria situada em PageArr[2]. Solicitar a página de índice 0 devolveria a página lógica 2.

Era exatamente isso que ambos os caminhos de análise interna do HotPDF faziam. O caminho tradicional, utilizado para ficheiros PDF 1.3/1.4, e o caminho moderno, utilizado para documentos com fluxos de objetos (PDF 1.5+), construíam a matriz PageArr percorrendo os objetos indiretos na ordem física do ficheiro, em vez de seguirem a cadeia /Kids.

Confirmar a hipótese

Antes de efetuar qualquer correção, era necessário comprovar a divergência em vez de a presumir. A ferramenta de linha de comandos qpdf torna isto simples:

{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }

A extração de cada página individualmente e a verificação do tamanho dos ficheiros confirmaram o mapeamento: o que o PageArr[0] produzia era o conteúdo pertencente à página lógica 2, e o PageArr[2] continha a página lógica 1. O desvio circular constituía a prova irrefutável. Isto também explicava por que razão o problema surgia em múltiplos documentos de origem distintos: qualquer PDF onde os objetos de página tivessem números de objeto inferiores aos de uma página lógica anterior desencadearia a falha.

Existe um motivo simples para os ficheiros PDF terminarem neste estado. As gravações incrementais anexam objetos atualizados com novos números de objeto, deixando as posições antigas na tabela de referências cruzadas sem apontar para lado nenhum. Os editores que adicionam uma página de capa inserem-na com um número de objeto elevado, independentemente da sua posição na matriz Kids. Alguns geradores limitam-se a escrever as páginas numa ordem conveniente para o fluxo de conteúdo, em vez da sequência de páginas lógica. O formato PDF não exige que façam o contrário.

A resolução: seguir a matriz Kids

A abordagem correta consiste em construir a matriz PageArr percorrendo a cadeia /Kids a partir da raiz do catálogo, e não analisando os objetos indiretos. Após ambos os caminhos de análise concluírem a sua passagem inicial, uma etapa de pós-processamento resolve a ordem lógica:

procedure THotPDF.ReorderPageArrByPagesTree;
var
  PagesObj  : THPDFDictionaryObject;
  KidsArray : THPDFArrayObject;
  NewPageArr: array of THPDFDictArrItem;
  I, J, PageIndex, KidsIndex: Integer;
  RefObj    : THPDFLink;
  PageObjNum: Integer;
  Found     : Boolean;
begin
  { Locate root /Pages dictionary via FRootIndex }
  PagesObj := FindPagesRootFromCatalog;
  if PagesObj = nil then Exit;

  KidsIndex := PagesObj.FindValue('Kids');
  if KidsIndex < 0 then Exit;
  KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));

  SetLength(NewPageArr, KidsArray.Items.Count);
  PageIndex := 0;

  for I := 0 to KidsArray.Items.Count - 1 do
  begin
    RefObj     := THPDFLink(KidsArray.GetIndexedItem(I));
    PageObjNum := RefObj.Value.ObjectNumber;

    Found := False;
    for J := 0 to Length(PageArr) - 1 do
    begin
      if PageArr[J].PageLink.ObjectNumber = PageObjNum then
      begin
        NewPageArr[PageIndex] := PageArr[J];
        Inc(PageIndex);
        Found := True;
        Break;
      end;
    end;
    { Non-page Kids (intermediate /Pages nodes) produce no match; skip }
  end;

  if PageIndex > 0 then
  begin
    SetLength(PageArr, PageIndex);
    for I := 0 to PageIndex - 1 do
      PageArr[I] := NewPageArr[I];
  end;
end;

A chamada é inserida no final de cada caminho de análise, após todos os objetos terem sido catalogados, mas antes de qualquer operação de página ser executada:

{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;

{ Modern path (object streams) }
if TryParseModernPDF then
begin
  Result := ModernPageCount;
  ReorderPageArrByPagesTree;
  Exit;
end;

A etapa de reordenação é O(n * m), onde n é a contagem de Kids e m é o comprimento atual de PageArr. No entanto, para qualquer documento com uma árvore de páginas plana (todas as folhas ao nível 1, o que abrange a grande maioria dos ficheiros PDF reais), ambos têm o mesmo valor e o custo é insignificante. Árvores de páginas profundamente aninhadas requerem uma travessia recursiva em vez da abordagem de nível único aqui ilustrada; a implementação em produção trata esse caso separadamente.

Utilizar CopyPageFromDocument após a correção

Com a rotina ReorderPageArrByPagesTree implementada, os índices de página lógicos funcionam como esperado. O método de nível superior CopyPageFromDocument recebe um índice lógico baseado em 0 e copia a página correta para o documento de destino:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copy logical page 0 (first page the user sees) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

O método CopyPageFromDocument consulta internamente a ordem da árvore de páginas em vez de confiar no índice bruto de PageArr, pelo que funciona corretamente mesmo em documentos onde a ordem física e a lógica divergem. Para operações em lote, a rotina InsertPagesFromDocument aceita uma matriz de índices lógicos e copia-os numa única passagem.

O que isto revela sobre a análise de PDF

A especificação do PDF é clara: a ordem lógica das páginas é definida pela matriz /Kids da árvore de páginas, e não por números de objetos ou offsets de bytes (ISO 32000-1 §7.7.3.2). Qualquer analisador que utilize uma ordenação diferente como atalho produzirá resultados corretos na maioria dos documentos que processa, porque a maior parte dos geradores escreve as páginas na ordem natural e atribui números de objetos sequenciais. O erro permanece oculto até que alguém carregue um PDF que tenha sido editado de forma incremental, reorganizado por outra ferramenta ou gerado por software com um esquema de layout diferente.

Testar apenas com ficheiros PDF gerados internamente ignora por completo este tipo de problema. A correção de uma regressão na ordenação de páginas requer, portanto, um conjunto de documentos de origens variadas: gravações incrementais, documentos digitalizados com páginas de capa inseridas ou ficheiros PDF produzidos por ferramentas que linearizam ou otimizam o grafo de objetos de forma distinta. O documento que desencadeou o erro original deve permanecer permanentemente no conjunto de testes de regressão.

A página do Componente HotPDF cobre a API completa para operações de página, incluindo CopyPageFromDocument, InsertPagesFromDocument e MovePage.