Artigo Técnico

Carregando PDFs de Referência Híbrida do Word e Excel no Delphi

Abra um PDF produzido pelo Microsoft Word ou Excel, percorra as páginas e nada parece incomum. Carregue-o em um programa Delphi, leia de volta a contagem de páginas e o número está correto. Em seguida, salve-o novamente com a criptografia ativada e o trabalho falha com um EListError, ou a saída é aberta com um aviso de referência cruzada danificada. O arquivo nunca esteve corrompido. É um arquivo de referência híbrida, e a mesma estrutura que permite que um visualizador de quinze anos o abra é a estrutura que derrota um carregador que para de ler muito cedo

Esta é uma das maneiras mais comuns de um pipeline de PDF que passou em todos os testes internos encontrar um arquivo que não consegue salvar de volta (round-trip). As entradas foram todas geradas internamente, então elas nunca foram híbridas. O primeiro arquivo híbrido chega no dia em que um cliente encaminha uma fatura exportada de uma planilha

O que o Word e o Excel realmente escrevem

O ISO 32000-1 descreve o layout de referência híbrida na §7.5.8.4. Um aplicativo que deseja os recursos do PDF 1.5, como fluxos de objetos, e ao mesmo tempo permitir que um leitor de PDF 1.4 abra o arquivo, grava as informações de referência cruzada duas vezes. Existe uma tabela de referência cruzada clássica, as linhas ASCII de largura fixa que terminavam todos os PDFs até a versão 1.4, e existe um fluxo de referência cruzada que indexa o restante. O trailer da seção clássica contém uma entrada /XRefStm cujo valor é o deslocamento em bytes daquele fluxo

A divisão de trabalho é deliberada. Os objetos que um leitor antigo tem que alcançar, o catálogo e a árvore de páginas entre eles, são endereçáveis ​​a partir da tabela clássica. Os objetos que foram dobrados em fluxos de objetos compactados são marcados como livres na tabela clássica, com uma entrada do tipo f, para que um leitor 1.4 passe direto por eles e nunca tropece em uma estrutura que não consiga analisar. Suas localizações reais residem apenas no fluxo de referência cruzada. A assinatura de um arquivo desse tipo é sua cauda: uma seção clássica curta, frequentemente nada mais do que xref seguido por um cabeçalho de subseção 0 0, cujo trailer aponta para o /XRefStm onde estão os dados reais de recuperação

Por que uma contagem de páginas correta não prova nada

Como o catálogo e a árvore de páginas podem ser acessados ​​da tabela clássica de propósito, um carregador que lê apenas essa tabela encontra o /Root, percorre a árvore de páginas e relata o número correto de páginas. Tudo que um leitor antigo precisa está presente, então o arquivo parece saudável. Os objetos que desapareceram são os agrupados em fluxos de objetos: dicionários de campos AcroForm, elementos de estrutura de PDF com tags, a longa cauda de pequenos dicionários que nunca precisaram ficar visíveis para um visualizador legado

Você não percebe a lacuna até que algo toque nesses objetos, e um salvamento completo toca em todos eles. Percorrer o documento para criptografá-lo ou reescrevê-lo novamente é precisamente a operação que pede cada número de objeto, um por um, e é por isso que o sintoma surge no momento de salvar e não no momento do carregamento, longe da sua causa

A armadilha é um detector que vê o xref e para

A maneira mais barata de decidir como um arquivo é indexado é seguir o startxref e inspecionar os primeiros bytes para os quais ele aponta. A palavra-chave xref significa uma tabela clássica; um objeto de fluxo significa um fluxo de referência cruzada. Esse teste é correto para qualquer arquivo que se comprometa com um único esquema. É errado para um arquivo híbrido, cujo startxref aponta para uma seção clássica com o único propósito de satisfazer leitores antigos, enquanto o /XRefStm no trailer dessa seção é onde a maior parte do documento é realmente indexada. Um detector que retorna "clássico" no primeiro xref que encontra nunca lê /XRefStm, e cada objeto que reside apenas no fluxo torna-se invisível

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

Com o detector de saída antecipada em vigor, o carregamento parece normal e o salvamento é onde os objetos ausentes se anunciam. A solução não é ler mais bytes no início; é reconhecer o trailer híbrido e seguir /XRefStm antes de decidir que o arquivo está concluído

A ordem de mesclagem não é negociável

Uma vez que ambos os índices tenham sido lidos, eles só podem ser combinados em uma direção. O fluxo de referência cruzada tem que ser mesclado primeiro, com as entradas clássicas preenchidas em torno dele. O motivo é a pequena decepção no coração do formato. Um arquivo híbrido marca seus objetos compactados como livres na tabela clássica para que leitores antigos os ignorem. Um carregador que honra uma política de 'o primeiro visto vence' e lê a tabela clássica primeiro registrará esses números de objeto como livres e descartará as entradas de fluxo que realmente os localizam, porque os slots já estão ocupados. Inverta a ordem e as entradas do tipo 2 do fluxo, cada uma sendo um número de fluxo de objeto mais um índice, ganham os slots que deveriam possuir, e as entradas clássicas se acomodam ao redor delas

A mesma disciplina protege contra a ressurreição de um objeto excluído por uma revisão mais antiga. Atualizações incrementais encadeiam-se de volta através de /Prev, e uma entrada livre do tipo 0 é uma sentinela de que uma seção mais recente aposentou um número de objeto. Não se deve permitir que uma seção posterior e mais antiga da cadeia sobrescreva aquela sentinela com um local desatualizado. Trate o 'primeiro a ser visto' como definitivo para os marcadores livres e o objeto excluído continuará excluído; trate-o sem cuidado e a própria história de um arquivo reanimará o conteúdo que a última revisão removeu

O que isso significa no HotPDF

O mecanismo resolve arquivos de referência híbrida para você, e faz isso em cada caminho que precisa analisar os dados de referência cruzada. Carregue um documento com LoadFromFile ou LoadFromStream, faça suas alterações e chame SaveLoadedDocument; ou execute uma operação de uso único, como EncryptFile, que lê uma entrada e escreve uma saída. De qualquer forma, a recuperação lê o /XRefStm, mescla a seção de fluxo à frente das entradas clássicas e resolve os objetos que vivem em fluxos antes que a gravação os enumere. O caminho de criptografia AES-256 é onde o problema se manifestou pela primeira vez, porque criptografar um documento reescreve todos os objetos e, portanto, exige que cada objeto já tenha sido localizado

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

O detalhe que vale a pena levar reside a montante da API. Arquivos que chegam do Word, Excel, PowerPoint e de uma longa lista de pipelines do tipo "Salvar como PDF" são rotineiramente híbridos, de modo que um carregador que você exercita apenas com a saída do seu próprio gerador pode nunca encontrar um durante os testes. Preencha seus ambientes de teste com documentos exportados de aplicativos Office reais, não apenas com arquivos que seu próprio código produziu

Verificando um arquivo suspeito

Duas inspeções resolvem a questão rapidamente. Abra o arquivo numa visualização hexadecimal e leia os bytes após o startxref final; um arquivo híbrido mostra uma pequena seção clássica cujo dicionário do trailer contém /XRefStm. Ou compare a contagem de objetos que uma análise completa relata com o maior número de objeto que o /Size declara no trailer. Uma grande lacuna significa que os objetos estão escondidos em fluxos que o carregador não abriu, o que é a mesma falha que se transforma num erro de salvamento mais tarde

A cauda de uma exportação típica do Excel torna a primeira verificação concreta. Tudo após a palavra-chave final xref é puro ASCII, portanto, a assinatura é legível diretamente de uma visualização hexadecimal (deslocamentos ilustrativos, anotações adicionadas)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

A subseção 0 0 é o indício: uma tabela clássica com zero entradas existe apenas para carregar o trailer, e o trailer existe principalmente para dizer /XRefStm 87325. Um detector que para na palavra-chave xref viu, neste ponto, um índice de nada. Quando você prefere fazer um script para a verificação em vez de analisar a olho nu, o marcador sempre fica nos últimos kilobytes do arquivo, portanto, uma leitura retroativa limitada é suficiente

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Trate a sonda como uma triagem, não como um analisador: ela diz quais arquivos num lote merecem atenção antes que um trabalho de salvamento seja executado, e nada mais. O que um carregador deve então fazer com o deslocamento que encontrar, seguindo a cadeia da seção, mesclando as entradas de fluxo à frente das clássicas, respeitando as sentinelas de entrada livre, é explicado passo a passo em nosso artigo complementar sobre a manipulação de PDFs de referência híbrida de aplicativos do Office

O lado de gravação desta história, ou seja, como fluxos de objetos e referências cruzadas compactadas são produzidos em primeiro lugar, é abordado em nosso artigo sobre fluxos de objetos e atualizações incrementais. Quando o arquivo híbrido em questão também é muito grande, as técnicas de carregamento explicadas no passo a passo da Direct File API para fluxos de trabalho de PDFs grandes permitem que você o inspecione sem ter que ler o arquivo inteiro na memória. Ambos combinam naturalmente com a recuperação descrita aqui, que é fornecida como parte do Componente HotPDF para Delphi e C++Builder junto com as APIs de carregamento, edição, criptografia e assinatura abordadas em outras partes deste blog