Artigo Técnico

Reconstruir Tabelas Xref de PDF Danificadas em Delphi

Quando uma tabela de cross-reference de um PDF fica inutilizável, a correção é ignorá-la por completo e reconstruí-la a partir do corpo do ficheiro. A PDFlibPas Delphi PDF Library faz isto com um scanner de tokens de passagem única que regista cada cabeçalho de objeto indireto genuíno que encontra, depois recupera o dicionário do trailer e entrega a tabela reconstruída ao carregador normal

O que se estraga primeiro quando um PDF fica danificado

A tabela de cross-reference é a parte mais frágil de um PDF, porque é a única parte que guarda offsets absolutos em bytes. O ISO 32000-1 §7.5.4 define essas entradas como offsets de dez dígitos a partir do início do ficheiro, e o §7.5.5 coloca a palavra-chave startxref perto do fim, a apontar para a própria tabela. Cada um desses números é invalidado por qualquer edição que desloque bytes. Uma sessão de FTP que correu em modo texto e traduziu CRLF, uma transferência truncada, um setor que se avariou numa unidade partilhada, uma ferramenta em lote que acrescentou dados sem escrever corretamente uma atualização incremental: todos deixam os dados dos objetos perfeitamente legíveis e o índice a apontar para lixo

É por isso que "o ficheiro está danificado e está a ser reparado" é uma janela tão comum. Os bytes estão quase sempre lá. O que desaparece é o mapa. A reconstrução não é, portanto, uma recuperação forense de dados perdidos, é a reconstrução de um índice que pode ser derivado do corpo, e tem sucesso muito mais vezes do que os utilizadores esperam, porque o conteúdo dispendioso, as árvores de páginas, os tipos de letra e as imagens, permanece intocado

Porque é que procurar por N 0 obj encontra falsos positivos?

Uma reconstrução ingénua procura nos bytes brutos o padrão "inteiro, inteiro, obj" e regista cada ocorrência. Encontra demasiado. O PDF é um formato contentor, e três regiões de um ficheiro são opacas à gramática de objetos: comentários (§7.2), strings (§7.3.4) e dados de stream (§7.3.8). Qualquer uma delas pode conter bytes que se leem exatamente como um cabeçalho de objeto, e nenhuma delas é um cabeçalho de objeto. Uma legenda numa string literal, um comentário de debug esquecido, ou dois megabytes de saída Flate ou DCT vão todos 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;

Cada entrada falsa custa duas vezes. Polui a tabela reconstruída com um número de objeto que não existe, e pode sobrepor-se a um objeto real com o mesmo número que aparece mais tarde no ficheiro. A PDFlibPas, por isso, não faz correspondência de padrões de todo. Tokeniza, o que significa que sabe sempre 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 percorre o ficheiro inteiro exatamente uma vez, em blocos de 64 KiB, com uma máquina de estados construída sobre as regras de tokens do ISO 32000-1 §7.2 e a sintaxe de objeto indireto do §7.3.10. Um token termina em espaço em branco ou num dos carateres delimitadores, e um cabeçalho de objeto só é registado quando se viu 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. O offset registado é o início do token do número de objeto, que é o que uma entrada de cross-reference tem de 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 pormenor importante é que o estado do token e o estado de string sobrevivem a uma fronteira de bloco. Um cabeçalho que atravessa a linha dos 65536 bytes continua a ser reconhecido, porque o token parcial, o par de inteiros pendente e as flags de dentro-de-string transitam todos para o bloco seguinte. Os buffers são fixos: 64 KiB para o scan, 32 bytes para o token mais longo que possa alguma vez importar, e os únicos arrays que crescem com o ficheiro são as listas de número de objeto, número de geração e offset de 64 bits, que são proporcionais à contagem real de objetos e não ao tamanho do ficheiro. 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 viável nas entradas de várias centenas de megabytes discutidas no artigo sobre acesso direto para juntar e dividir

Porque é que não se pode confiar que um stream termina em endstream?

Porque os dados de stream são bytes arbitrários, e bytes arbitrários podem soletrar endstream por acaso. Um stream que começa depois da palavra-chave stream tem de ser ignorado como dados opacos até terminar genuinamente, mas a primeira ocorrência da palavra-chave de fecho é apenas uma candidata. A PDFlibPas resolve isto exigindo corroboração: um token endstream só é aceite como o fim real do stream quando o token seguinte não em branco é um endobj isolado, a sequência que o §7.3.8 exige à volta de um objeto stream. Uma coincidência dentro de dados comprimidos quase nunca tem esse seguimento, pelo que o scanner permanece dentro do stream e continua. Duas regras mais pequenas importam da mesma forma. A palavra-chave stream só entra em estado de stream quando é uma palavra-chave isolada, pelo que um objeto nome como /stream num dicionário nunca a dispara. E um token obj ou trailer só é honrado quando o token não ultrapassou o limite de 32 bytes e não começou por uma barra. Sem essas duas proteções, um dicionário de recursos com os nomes de chave errados seria suficiente para descarrilar o scan, que é exatamente a classe de entrada adversarial coberta nas notas sobre analisar PDFs não fiáveis com segurança

Encontrar o fim real do dicionário do trailer

Recuperar os objetos é apenas metade do trabalho, porque o carregador ainda precisa de um trailer para encontrar o /Root. A PDFlibPas guarda as últimas 64 posições da palavra-chave trailer encontradas durante o scan e valida-as ao contrário, começando pela mais recente, de modo que o trailer utilizável mais recente vence e uma palavra-chave perdida que não é seguida de um dicionário simplesmente falha a validação e passa para o candidato anterior. Cada candidato é lido com um limite de 1 MiB, e o fim do dicionário é localizado seguindo a profundidade aninhada de << e >> juntamente com escapes de strings literais, 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 rastreio de profundidade não é académico. Um trailer truncado que perde o /Encrypt transforma um documento encriptado recuperável num que não abre, e perder o /Info ou um subdicionário personalizado descarta silenciosamente metadados de que um sistema a jusante pode depender. Se o ficheiro estiver encriptado, o trailer recuperado é o que permite que o percurso normal de credenciais corra, e a semântica de repetição é a mesma descrita no artigo sobre o carregamento de documentos encriptados

O que a reconstrução não pode devolver

A reconstrução é um melhor esforço, e ser honesto sobre os seus limites faz parte de a lançar em produção. Três casos falham redondamente. Objetos empacotados dentro de object streams (§7.5.7) não são individualmente visíveis a um scan de bytes, pelo que se um contentor sobrevive mas a sua cross-reference stream (§7.5.8) não, os objetos que contém não ficam indexados pela reconstrução. Um ficheiro cujo corpo foi de facto corrompido, e não apenas mal indexado, vai produzir cabeçalhos cujo conteúdo já não se consegue analisar. E um ficheiro sem palavra-chave trailer recuperável e sem catálogo legível não tem nada a que ancorar uma árvore de documento, independentemente de quantos cabeçalhos de objeto foram encontrados

Números de objeto duplicados são o caso intermédio interessante. Um ficheiro atualizado incrementalmente contém legitimamente várias gerações do mesmo número de objeto, e a cadeia de cross-reference sobrevivente é o único registo de qual delas estava atual. Uma reconstrução não tem essa cadeia, pelo que regista cada cabeçalho que encontra pela ordem do ficheiro e resolve por número de objeto a seguir. Normalmente vence a revisão mais recente, o que normalmente é correto, mas um documento que foi atualizado e depois parcialmente revertido pode voltar subtilmente diferente do que o xref original descrevia. Os ficheiros linearizados carregam a mesma ressalva na direção oposta: o layout da primeira página e as tabelas de hint perdem o sentido assim que o índice é regenerado, pelo que um ficheiro 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 executa o scan em bruto sempre que a cadeia de cross-reference não pode ser lida, e também quando toda a entrada em uso declara offset zero, que é a assinatura de uma tabela que foi escrita mas nunca preenchida. GetDocumentRepaired devolve 1 quando esse caminho correu, e vale a pena registá-lo em vez de o ignorar, porque um documento que carregou através de reconstrução deveria ser regravado num ficheiro limpo em vez de ficar num pipeline como se nada tivesse acontecido. Gravá-lo escreve uma tabela de cross-reference nova e coerente, que é a correção mais barata possível para todos os consumidores a jusante

O caminho de reconstrução, a flag GetDocumentRepaired e o carregador em streaming aqui mostrados fazem parte da PDFlibPas Delphi PDF Library, a par das APIs de análise, renderização e assinatura cobertas noutros artigos deste blog