Artigo Técnico

Estrutura do Arquivo PDF: Cabeçalho, Corpo, Xref e Trailer

Um PDF cortado no meio de um download pode se recusar a abrir mesmo quando quase todas as páginas chegaram, mas um manual de 2.000 páginas salta para a página 1.500 instantaneamente. Os dois comportamentos decorrem da estrutura de arquivo do PDF, como o HotPDF Delphi Component a escreve: um cabeçalho, um corpo de objetos numerados, uma tabela de referência cruzada (xref) de offsets de bytes e um trailer, os dois últimos armazenados no final

A ISO 32000-2 §7.5.1 descreve a mesma anatomia de quatro partes para todo PDF, de um recibo de uma página a um acervo escaneado. As partes sempre ficam na mesma ordem descendo o arquivo, e se conectam numa cadeia fixa: o trailer nomeia o catálogo e diz onde a tabela xref começa, a tabela xref diz onde cada objeto começa, e os objetos apontam uns para os outros por número. Os trechos abaixo vêm de um arquivo Hello World de 610 bytes cujos offsets foram conferidos byte a byte; o arquivo completo, anotado palavra-chave por palavra-chave, está em como um PDF é por dentro, e o que um visualizador faz com essas partes a caminho dos pixels está em como um PDF funciona quando um leitor o abre

Diagrama do formato de arquivo PDF da estrutura em disco de um arquivo PDF: cabeçalho, corpo de objetos, tabela de referências cruzadas e trailer
Diagrama da estrutura de arquivo PDF: cabeçalho, corpo de objetos, tabela de referências cruzadas e trailer ficam em uma única ordem fixa no disco, e o trailer no fim amarra os outros três

O cabeçalho do PDF tem duas linhas, e a segunda importa

A primeira linha, como %PDF-1.7, é um comentário no que a sintaxe concerne, mas os leitores a tratam como a assinatura do arquivo e extraem a versão dela; o tratamento de versão é flexível na prática, então trate o número como uma dica de quais recursos esperar. A segunda linha é a que as pessoas excluem por acidente e depois passam uma tarde depurando. A carga útil dela é de pelo menos quatro bytes acima do ASCII 127 (ISO 32000-1 §7.5.2), lá para que qualquer coisa que mova o arquivo em modo texto o reconheça como binário e pare de reescrever fins de linha. Streams comprimidos contêm bytes que batem com um carriage return ou um line feed por coincidência; reescreva-os e o comprimento do stream registrado no dicionário deixa de bater com os bytes no disco. Todo escritor sério ainda emite o comentário porque a falha que ele previne é silenciosa e total

O corpo: objetos numerados e os oito tipos de objeto do PDF

O corpo é uma sequência plana de objetos indiretos, cada um empacotado como N G obj ... endobj com um número de objeto e um número de geração. O que transforma essa lista plana na estrutura interna do PDF é o token de referência N G R: 2 0 R significa "objeto 2, geração 0, onde quer que ele more no arquivo". Qualquer valor também pode ser escrito no lugar como um objeto direto. No trecho, o dicionário /Resources da página é direto, enquanto a fonte que ele nomeia é indireta, então cem páginas podem compartilhar o objeto 4 em vez de carregar cem cópias:

1 0 obj
<< /Type /Catalog /Pages 2 0 R >>          % o catálogo aponta para a árvore de páginas
endobj
...
3 0 obj
<< /Type /Page
   /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 4 0 R >> >>   % dicionário direto, fonte indireta
   /Contents 5 0 R
>>
endobj
Anatomia PDF de uma referência indireta mostrando o objeto de fonte doze definido uma única vez e acessado a partir do dicionário de recursos da página pela notação 12 0 R
Um objeto ganha identidade por meio de seus números de objeto e geração, e quem o consome o alcança onde precisar escrevendo a referência R correspondente em vez de uma cópia

A ISO 32000-1 §7.3 mantém o vocabulário curto: todo valor em um PDF é um de oito tipos básicos. Booleans e números são os escalares, e o PDF não tem notação exponencial, então 1e6 nunca aparece num arquivo em conformidade. Strings guardam bytes, escritos como (Hello, World!) ou em hex como <48656C6C6F>. Names como /Type ou /Helvetica são comparados byte a byte, então escrever (Helvetica) onde o /BaseFont espera um name é um erro de tipo mesmo com os caracteres batendo. Arrays guardam qualquer coisa em ordem, como o MediaBox. Dictionaries mapeiam names para valores e carregam a maior parte da estrutura. Um stream é um dictionary mais bytes brutos, lar do conteúdo de página, das fontes e das imagens. O oitavo tipo é null, e uma referência a um objeto que não existe resolve para ele em vez de levantar um erro, o que deixa um arquivo danificado se degradar em vez de falhar por completo

Grade de cartões PDF dos oito tipos de objeto do PDF com sintaxe de exemplo para boolean, number, string, name, array, dictionary, stream e null
Oito tipos primitivos formam todo o vocabulário de valores do PDF; o dicionário faz o trabalho estrutural pesado, enquanto os streams carregam a carga comprimida volumosa

O número de geração existe para a reutilização. Quando uma edição deleta um objeto, o número dele vai para a free list, e se uma edição posterior reutilizar o número, a geração sobe um, então um 12 0 R obsoleto não pode resolver silenciosamente para o novo 12 1 obj. Quase todo objeto que você encontrar está na geração 0. A resolução começa no catálogo, objeto 1 no trecho, cuja entrada /Pages leva à árvore de páginas; nós internos reúnem páginas em /Kids, mantêm um /Count e podem passar atributos como /MediaBox para os descendentes deles. Por que essa árvore, e não a ordem física dos objetos, decide a ordem das páginas está coberto em árvores de páginas do PDF e a ordem das páginas

Como a tabela xref localiza um objeto?

A tabela xref transforma um número de objeto em um offset de bytes por aritmética, que é como um manual de 2.000 páginas abre na página 1.500 sem parsear as páginas anteriores. A ISO 32000-1 §7.5.4 fixa toda entrada em exatamente vinte bytes: um offset de dez dígitos contado do início do arquivo, um espaço, uma geração de cinco dígitos, um espaço, uma letra de tipo e um fim de linha de dois bytes, que com LF é um espaço mais o line feed. Como as linhas são uniformes, um leitor indexa direto para a linha do objeto n. A linha 0 6 é um cabeçalho de subseção, e a entrada 0 é sempre livre com geração 65535, encabeçando a lista ligada de números de objetos livres. Todo outro offset precisa aterrissar no primeiro dígito do header N G obj correspondente, como no arquivo de 610 bytes:

xref
0 6                  % 6 entradas, começando no objeto 0
0000000000 65535 f   % entrada 0: cabeça da free list
0000000019 00000 n   % o objeto 1 (catálogo) começa no byte 19
0000000068 00000 n   % o objeto 2 (árvore de páginas) começa no byte 68
0000000125 00000 n   % o objeto 3 (página) começa no byte 125
0000000263 00000 n   % o objeto 4 (fonte) começa no byte 263
0000000333 00000 n   % o objeto 5 (content stream) começa no byte 333
Anatomia PDF de uma entrada xref de PDF de vinte bytes cujo offset de bytes de dez dígitos permite a um leitor saltar a qualquer objeto sem varredura
Linhas uniformes de vinte bytes transformam um número de objeto em um offset de bytes por aritmética, e o objeto 0 encabeça a free list

Por que um PDF truncado falha ao abrir?

Um PDF truncado falha porque as duas partes que amarram tudo, a tabela xref e o trailer, são os últimos bytes escritos. O dicionário de trailer carrega /Root, o catálogo, e /Size, o número de entradas xref, um a mais que o maior número de objeto porque o slot zero guarda a entrada livre. Abaixo dele, o startxref dá o offset em bytes da palavra-chave xref e o %%EOF fecha o arquivo:

trailer
<< /Size 6 /Root 1 0 R >>   % contagem de entradas, catálogo do documento
startxref
427                          % offset em bytes da palavra-chave xref
%%EOF

Perca os últimos poucos centenas de bytes e o arquivo tem objetos mas nenhum mapa para eles. Leitores estritos param aí; tolerantes varrem o arquivo inteiro por headers N G obj e reconstruem a tabela, o caminho lento que a xref existe para evitar, e um mapa reconstruído ainda pode perder objetos ou pegar a cópia errada de um editado. Uma transferência em modo texto faz um dano mais silencioso, porque reescrever fins de linha desloca todo objeto. Nós convertemos o arquivo de 610 bytes para fins de linha CRLF e rodamos o qpdf --check (qpdf 12.3.2): ele reportou "file is damaged" e "xref not found" no offset 427, e reconstruiu a tabela varrendo antes de conferir qualquer coisa

Tabela xref clássica ou cross-reference stream?

Desde o PDF 1.5, a xref também pode ser um cross-reference stream (ISO 32000-1 §7.5.8): um objeto indireto de /Type /XRef cujas linhas binárias substituem a tabela de texto e cujo dicionário de stream absorve as entradas do trailer, então a palavra-chave trailer desaparece e o startxref aponta para aquele objeto. Os tipos de linha viram números (0 livre, 1 em um offset de bytes, 2 dentro de um object stream), o /W dá a largura em bytes de cada coluna, e o HotPDF comprime as linhas com Flate e o predictor PNG Up. O HotPDF escreve a forma de stream quando o Version é pdf15 ou posterior, então o exemplo o define explicitamente, e o UseObjectStreams se apoia na mesma chave. Nós deixamos o UseXRefStream desligado para arquivos que precisam abrir em ferramentas da era do PDF 1.4, que não conseguem parsear um xref stream de forma alguma, e o ligamos para documentos grandes onde um índice compacto compensa. Só a cauda do arquivo muda:

uses
  System.SysUtils, HPDFDoc;

procedure SaveWithXRef(const FileName: string; AsStream: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.FileName := FileName;
    PDF.Version := pdf17;            // xref streams precisam de PDF 1.5 ou posterior
    PDF.UseXRefStream := AsStream;   // default False: tabela de texto mais trailer
    PDF.BeginDoc;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Same page, different index');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

// SaveWithXRef('table.pdf', False);
//   Cauda esperada: xref, 0 N, linhas de vinte bytes,
//   trailer << /Size /Root /Info /ID >>, startxref, %%EOF
// SaveWithXRef('stream.pdf', True);
//   Cauda esperada: N 0 obj << /Type /XRef /Size /Root /Info /ID /W ...
//   /Filter /FlateDecode >> stream ... endstream endobj, startxref, %%EOF

Atualizações incrementais anexam um segundo xref em vez de reescrever

Um PDF pode ser editado sem tocar em nenhum byte já no disco. Objetos alterados e novos são anexados, seguidos de uma seção xref que lista só eles e de um trailer cuja entrada /Prev guarda o offset da tabela anterior. Um leitor segue o startxref até a seção mais nova, caminha pela cadeia /Prev para trás, e deixa a entrada mais nova de cada número de objeto ganhar. A edição abaixo adiciona uma anotação de nota ao arquivo de 610 bytes: o objeto de página 3 é reescrito com um array /Annots sob o mesmo número, e o objeto 6 é novo. Com os comentários removidos e fins de linha LF os offsets são exatos, e o qpdf --check passou no resultado sem erros:

% ... 610 bytes originais, inalterados ...
3 0 obj                          % substitui a página 3, mesmo número de objeto
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R] >>
endobj
6 0 obj                          % a nova anotação
<< /Type /Annot /Subtype /Text /Rect [72 650 92 670] /Contents (Checked) >>
endobj
xref                             % duas subseções de uma entrada
3 1
0000000610 00000 n
6 1
0000000752 00000 n
trailer
<< /Size 7 /Root 1 0 R /Prev 427 >>
startxref
843
%%EOF
PDF: atualização incremental de PDF anexando uma segunda seção xref e trailer encadeados por /Prev enquanto os bytes originais e faixas assinadas permanecem intocados
Uma edição acrescenta objetos novos mais um xref e trailer novos encadeados através de /Prev; o intervalo de bytes original nunca se move, e é isso que mantém as assinaturas válidas

Anexar é o que mantém PDFs assinados verificáveis: uma assinatura cobre uma faixa de bytes, e uma atualização incremental nunca move esses bytes. É também por isso que um PDF carrega histórico recuperável, já que a antiga página 3 ainda está no byte 125, inalcançável mas presente. Arquivos revisados muitas vezes crescem uma longa cadeia /Prev; qpdf revised.pdf flat.pdf re-serializa os objetos alcançáveis sob uma única xref e descarta as revisões antigas, o que no arquivo acima deixou uma tabela xref e nenhum /Prev, ao preço de qualquer assinatura sobre os bytes originais. No HotPDF, uma revisão incremental fica assim:

procedure ReviseInPlace(const Original, Revised: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.BeginIncrementalUpdate(Original);   // mantém os bytes originais ao pé da letra
    PDF.SetLoadedTitle('Quarterly report, revised');
    PDF.SetLoadedAuthor('Finance');
    PDF.SaveIncrementalUpdate(Revised);      // objetos alterados, xref novo, trailer /Prev
  finally
    PDF.Free;
  end;
end;

O layout de quatro partes também dá uma ordem de triagem rápida para um PDF que se comporta mal: confira que o arquivo termina em %%EOF com um startxref plausível, confirme que um ou dois offsets xref aterrissam num header N G obj, e depois siga o /Root para dentro do catálogo e da árvore de páginas. O HotPDF Delphi Component para Delphi e C++Builder escreve tabelas xref clássicas ou cross-reference streams a pedido e anexa revisões incrementais que deixam os bytes anteriores, e as assinaturas sobre eles, intactos