O sintoma apareceu em um utilitário de cópia de página construído sobre o Componente HotPDF: solicitar a página 1 de um documento de três páginas consistentemente produzia a página 2. Verificar a lógica de indexação não revelou nada de errado. A chamada estava usando um índice lógico baseado em 0, a aritmética estava correta, as condições de contorno estavam boas. No entanto, a página errada saía todas as vezes
O bug não estava no código de cópia de forma alguma. Estava em como o HotPDF estava construindo seu array interno de páginas ao carregar o arquivo

Duas ordenações, uma fonte de confusão
Um arquivo PDF é uma coleção de objetos indiretos, cada um identificado por um número de objeto. A estrutura do arquivo não impõe nenhuma obrigação a esses números de refletir 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 (page tree): uma hierarquia de dicionários /Pages cujos arrays /Kids listam referências de página na sequência que um visualizador deve exibi-las (ISO 32000-1 §7.7.3)
O documento que disparava o bug tinha esta 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
Acontecia de o arquivo listar o objeto 1 e o objeto 4 antes do objeto 20 no fluxo de bytes. Qualquer analisador (parser) que iterasse pelos objetos indiretos na ordem do arquivo e os carimbasse em um PageArr à medida que encontrasse dicionários do tipo página acabaria com 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 fica em PageArr[2]. Pedir o índice de página 0 busca a página lógica 2 em vez disso
Isso era exatamente o que os dois caminhos de análise internos do HotPDF estavam fazendo. O caminho tradicional, usado para arquivos PDF 1.3/1.4, e o caminho moderno, usado para documentos com object stream (PDF 1.5+), construíam cada um o PageArr percorrendo objetos indiretos na ordem física do arquivo em vez de seguir a cadeia /Kids
Confirmando a hipótese
Antes de tocar em qualquer correção, a incompatibilidade precisava ser comprovada e não assumida. A ferramenta de linha de comando qpdf torna isso 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 }
Extrair cada página individualmente e verificar o tamanho dos arquivos confirmou o mapeamento: o que PageArr[0] produzia era o conteúdo pertencente à página lógica 2, e PageArr[2] mantinha a página lógica 1. A troca circular era a prova definitiva. Isso também explicava por que o problema aparecia em vários documentos de origem diferentes: qualquer PDF em que os objetos de página por acaso tivessem números de objeto mais baixos do que uma página lógica anterior iria desencadeá-lo
Há um motivo direto para os PDFs acabarem nesse estado. Salvamentos incrementais acrescentam objetos atualizados com novos números de objeto, deixando os espaços antigos na tabela de referência cruzada apontando para lugar nenhum. Editores que adicionam uma página de rosto a inserem com um número de objeto alto, independentemente de sua posição no array Kids. Alguns geradores simplesmente gravam as páginas em uma ordem conveniente para o fluxo de conteúdo, em vez da sequência lógica de páginas. O formato PDF não exige que eles façam de outra forma
A correção: seguir o array Kids
A abordagem correta é construir PageArr percorrendo a cadeia /Kids a partir da raiz do catálogo, e não fazendo a varredura de objetos indiretos. Depois que ambos os caminhos de análise concluem 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 entra no final de cada caminho de análise, depois que todos os objetos foram catalogados, mas antes que qualquer operação de página seja atendida:
{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Modern path (object streams) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
O passo de reordenação é O(n * m), onde n é a contagem de Kids e m é o comprimento atual do PageArr, mas para qualquer documento com uma árvore de páginas plana (todas as folhas na profundidade 1, o que cobre a esmagadora maioria dos PDFs do mundo real), ambos têm o mesmo valor e o custo é insignificante. Árvores de páginas profundamente aninhadas requerem uma caminhada recursiva em vez da abordagem de nível único mostrada aqui; a implementação de produção lida com esse caso separadamente
Usando CopyPageFromDocument após a correção
Com o ReorderPageArrByPagesTree no lugar, os índices lógicos de página funcionam conforme o esperado. O nível mais alto 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 CopyPageFromDocument consulta internamente a ordem na árvore de páginas em vez de confiar no índice bruto PageArr, portanto, ele se comporta corretamente mesmo em documentos onde a ordem física e lógica divergem. Para operações em lote, InsertPagesFromDocument aceita uma matriz de índices lógicos e os copia em uma única passagem
O que isso revela sobre a análise de PDF
A especificação do PDF é explícita: a ordem lógica das páginas é definida pelo array /Kids da árvore de páginas, não por números de objeto ou deslocamentos de bytes (ISO 32000-1 §7.7.3.2). Qualquer analisador que use uma ordenação diferente como um atalho produzirá resultados corretos na maioria dos documentos que encontrar, porque a maioria dos geradores grava as páginas na ordem natural e atribui números de objetos sequenciais. O bug se esconde até que alguém carregue um PDF que foi editado de forma incremental, reorganizado por outra ferramenta ou gerado por um software que escolheu um layout diferente
Testar apenas em PDFs gerados por si mesmo ignora totalmente essa classe de problemas. A correção para uma regressão na ordem das páginas precisa, portanto, de um corpus de documentos de fontes variadas: salvamentos incrementais, documentos digitalizados com páginas de rosto inseridas, PDFs produzidos por ferramentas que linearizam ou otimizam o grafo de objetos de forma diferente. Um documento que desencadeou o bug original deve permanecer no conjunto de testes de regressão permanentemente
A página do Componente HotPDF cobre a API completa para operações de página, incluindo CopyPageFromDocument, InsertPagesFromDocument e MovePage