Quando a cross-reference table de um PDF fica inutilizável, a solução é ignorá-la por completo e reconstruí-la a partir do corpo do arquivo. A PDFlibPas Delphi PDF Library faz isso com um scanner de tokens de passagem única que registra todo cabeçalho de objeto indireto genuíno que encontra, depois recupera o trailer dictionary e entrega a tabela reconstruída ao loader normal
O que quebra primeiro quando um PDF é danificado
A cross-reference table é a parte mais frágil de um PDF, porque é a única parte que armazena deslocamentos absolutos em bytes. A ISO 32000-1 §7.5.4 define essas entradas como deslocamentos de dez dígitos a partir do início do arquivo, e a §7.5.5 coloca a palavra-chave startxref perto do final apontando para a própria tabela. Cada um desses números é invalidado por qualquer edição que desloque bytes. Uma sessão de FTP que rodou em modo texto e traduziu CRLF, um download truncado, um setor que ficou ruim em um drive compartilhado, uma ferramenta em lote que anexou sem escrever um incremental update corretamente: todos eles deixam os dados de objeto perfeitamente legíveis e o índice apontando para lixo
É por isso que "o arquivo está danificado e está sendo reparado" é um diálogo tão comum. Os bytes quase sempre ainda estão lá. O que se perdeu é o mapa. A reconstrução, portanto, não é recuperação forense de dados perdidos, é a reconstrução de um índice que pode ser derivado do corpo, e ela tem sucesso com muito mais frequência do que os usuários esperam porque o conteúdo caro, árvores de páginas, fontes e imagens, permanece intocado
Por que escanear por N 0 obj encontra falsos positivos?
Uma reconstrução ingênua busca nos bytes brutos o padrão "inteiro, inteiro, obj" e registra todo acerto. Ela encontra demais. PDF é um formato container, e três regiões de um arquivo são opacas para a gramática de objeto: comentários (§7.2), strings (§7.3.4) e dados de stream (§7.3.8). Qualquer uma delas pode conter bytes que se lêem exatamente como um cabeçalho de objeto, e nenhuma delas é um cabeçalho de objeto. Uma legenda em uma string literal, um comentário de debug esquecido, ou dois megabytes de saída Flate ou DCT vão produzir alegremente algo que parece 99 0 obj
const
Trap: AnsiString =
'4 0 obj'#10 +
'(a caption that mentions 88 0 obj)'#10 + // literal string, not an object
'endobj'#10 +
'% 77 0 obj left over from a debug dump'#10 + // comment, not an object
'5 0 obj'#10 +
'<< /Length 2097152 >>'#10 +
'stream'#10 +
{ two MiB of compressed bytes that contain the byte sequence
99 0 obj and, further along, a complete endstream }
'endstream'#10 +
'endobj'#10;
Toda entrada falsa custa duas vezes. Ela polui a tabela reconstruída com um número de objeto que não existe, e pode ofuscar um objeto real com o mesmo número que aparece mais adiante no arquivo. Por isso a PDFlibPas não faz pattern-matching de forma alguma. Ela tokeniza, o que significa que sempre sabe se os bytes sob o cursor são código ou payload, e o payload é ignorado sem nunca ser interpretado
Uma máquina de estados de passagem única sobre blocos de 64 KiB
A PDFlibPas escaneia o arquivo inteiro exatamente uma vez, em blocos de 64 KiB, com uma máquina de estados construída sobre as regras de token da ISO 32000-1 §7.2 e a sintaxe de objeto indireto da §7.3.10. Um token termina em espaço em branco ou em um dos caracteres delimitadores, e um cabeçalho de objeto só é registrado quando uma sequência completa de um número de objeto positivo, um número de geração não negativo, e uma palavra-chave obj isolada foi vista. O deslocamento registrado é o início do token do número de objeto, que é o que uma entrada de cross-reference precisa apontar, não a posição da palavra-chave obj
function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
Result := (Value = 0) or (Value = 9) or (Value = 10) or
(Value = 12) or (Value = 13) or (Value = 32);
end;
function RebuildIsDelimiter(Value: Byte): Boolean;
begin
Result := (Value = Ord('(')) or (Value = Ord(')')) or
(Value = Ord('<')) or (Value = Ord('>')) or
(Value = Ord('[')) or (Value = Ord(']')) or
(Value = Ord('{')) or (Value = Ord('}')) or
(Value = Ord('/')) or (Value = Ord('%'));
end;
O detalhe importante é que o estado de token e o estado de string sobrevivem a uma fronteira de bloco. Um cabeçalho que atravessa a linha de 65536 bytes ainda é reconhecido, porque o token parcial, o par de inteiros pendente e as flags de dentro-de-string carregam para o próximo bloco. Os buffers são fixos: 64 KiB para o scan, 32 bytes para o maior token que pode importar, e os únicos arrays que crescem com o arquivo são as listas de número de objeto, número de geração e deslocamento de 64 bits, que são proporcionais à contagem real de objetos, não ao tamanho do arquivo. Na prática o scan emite leituras sequenciais e no máximo dois seeks explícitos sobre o documento inteiro, o que é o que torna isso viável nas entradas de centenas de megabytes discutidas no artigo sobre merge e split com acesso direto
Por que um stream não pode confiar em terminar em endstream?
Porque os dados de stream são bytes arbitrários, e bytes arbitrários podem soletrar endstream por acidente. Um stream que começa depois da palavra-chave stream precisa ser tratado como dado opaco até genuinamente terminar, mas a primeira ocorrência da palavra-chave de fechamento é só uma candidata. A PDFlibPas resolve isso exigindo corroboração: um token endstream só é aceito como o fim real do stream quando o próximo token que não é espaço em branco é um endobj isolado, a sequência que a §7.3.8 exige ao redor de um stream object. Um acerto casual dentro de dados comprimidos quase nunca tem esse seguimento, então o scanner permanece dentro do stream e continua. Duas regras menores importam tanto quanto. A palavra-chave stream só entra em estado de stream quando é uma palavra-chave isolada, então um name object como /stream em um dictionary nunca a dispara. E um token obj ou trailer só é honrado quando o token não excedeu o limite de 32 bytes e não começou com uma barra. Sem essas duas guardas, um resource dictionary com os nomes de chave errados seria suficiente para descarrilar o scan, que é exatamente a classe de entrada adversarial coberta nas notas sobre análise segura de PDF não confiável
Encontrando o fim real do trailer dictionary
Recuperar os objetos é só metade do trabalho, porque o loader ainda precisa de um trailer para encontrar /Root. A PDFlibPas guarda as últimas 64 posições da palavra-chave trailer encontradas durante o scan e as valida de trás para frente, a mais recente primeiro, de modo que o trailer utilizável mais novo vence e uma palavra-chave perdida que não é seguida por um dictionary simplesmente falha na validação e cai para a candidata anterior. Cada candidata é lida com um limite de 1 MiB, e o fim do dictionary é localizado rastreando a profundidade aninhada de << e >> junto com escapes de string literal, strings hexadecimais e comentários
// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
' /Custom << /Text (value >> preserved) >> >>'#10
O rastreamento de profundidade não é acadêmico. Um trailer truncado que perde /Encrypt transforma um documento criptografado recuperável em um que não pode ser aberto, e perder /Info ou um sub-dictionary customizado descarta silenciosamente metadados dos quais um sistema a jusante pode depender. Se o arquivo estiver criptografado, o trailer recuperado é o que permite que o caminho normal de credenciais rode, e a semântica de retry é a mesma descrita no artigo sobre carregamento de documentos criptografados
O que a reconstrução não consegue devolver
A reconstrução é um melhor esforço, e ser honesto sobre seus limites faz parte de lançá-la. Três casos falham completamente. Objetos empacotados dentro de object streams (§7.5.7) não são individualmente visíveis a uma varredura de bytes, então se um container sobrevive mas seu cross-reference stream (§7.5.8) não, os objetos que ele guarda não são indexados pela reconstrução. Um arquivo cujo corpo foi de fato corrompido, em vez de meramente desindexado, vai produzir cabeçalhos cujo conteúdo não faz mais parse. E um arquivo sem palavra-chave trailer recuperável e sem catálogo legível não tem nada para ancorar uma árvore de documento, não importa quantos cabeçalhos de objeto foram encontrados
Números de objeto duplicados são o caso intermediário interessante. Um arquivo atualizado incrementalmente legitimamente contém várias gerações do mesmo número de objeto, e a cadeia de cross-reference sobrevivente é o único registro de qual era a atual. Uma reconstrução não tem essa cadeia, então registra todo cabeçalho que vê na ordem do arquivo e resolve por número de objeto depois. Geralmente a revisão mais recente vence, o que geralmente está certo, mas um documento que foi atualizado e depois parcialmente revertido pode voltar sutilmente diferente do que o xref original descrevia. Arquivos linearizados carregam a mesma ressalva pelo lado oposto: o layout de primeira página e as hint tables perdem o sentido assim que o índice é regenerado, então um arquivo reparado deve ser tratado como um documento simples, não linearizado
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
begin
if Pdf.GetDocumentRepaired = 1 then
LogWarning('xref was unusable; the table was reconstructed');
if Pdf.PageCount > 0 then
Pdf.SaveToFile('recovered-invoice.pdf'); // writes a clean xref
end;
finally
Pdf.Free;
end;
end;
O fallback é automático: a PDFlibPas roda o scan bruto sempre que a cadeia de cross-reference não pode ser lida, e também quando toda entrada em uso alega deslocamento zero, o que é a assinatura de uma tabela que foi escrita mas nunca preenchida. GetDocumentRepaired retorna 1 quando esse caminho rodou, e vale a pena logar em vez de ignorar, porque um documento que carregou via reconstrução deveria ser regravado em um arquivo limpo em vez de deixado em um pipeline como se nada tivesse acontecido. Salvá-lo grava uma cross-reference table nova e consistente, o que é a correção mais barata possível para todo consumidor a jusante
O caminho de reconstrução, a flag GetDocumentRepaired e o loader em streaming mostrados aqui fazem parte da PDFlibPas Delphi PDF Library, junto com as APIs de parsing, renderização e assinatura cobertas em outros lugares neste blog