Artigo Técnico

Índice esparso lazy de objetos PDF em Delphi com PDFiumPas

Quer um único dicionário de um PDF de 2 GB e a ferramenta primeiro expande a tabela de referências cruzadas inteira num array dimensionado pelo /Size do trailer. O PDFiumPas substitui esse passo por um índice de objetos esparso e lazy: guarda apenas os descritores de secções xref, resolve um único número de objeto a pedido através de janelas limitadas, e faz cache só das entradas que realmente tocou

A forma antiga deste código no FPdfCompress era honesta mas cara. O ApplyDefaultOpenAction lia o ficheiro completo para um único TBytes, depois alocava um array denso TPdfActiveXrefEntries com uma posição por número de objeto até /Size. Duas coisas falhavam em escala. O custo de leitura crescia linearmente com o tamanho do documento mesmo quando o chamador queria quatro dicionários, e o array denso colidia com o orçamento do parser: TPdfParserResourceBudget.Default define MaxObjects como 4.000.000, pelo que um ficheiro perfeitamente válido cujo número de objeto mais alto ficasse acima desse teto era rejeitado por um argumento de memória e não de correção

O índice de objetos esparso e lazy do PDFiumPas em Delphi comparado com um array denso de referências cruzadas: o caminho denso lê o ficheiro inteiro e aloca uma posição por número de objeto até ao tamanho do trailer, enquanto o caminho esparso guarda apenas descritores de secções
Só os descritores ficam em memória, as entradas ficam no ficheiro, e cada leitura passa por uma janela limitada de um mebibyte

Porque é que a API pública do PDFium não responde a esta pergunta?

Porque a informação existe dentro do PDFium mas nunca atravessa a fronteira C. O CPDF_Parser mantém internamente a tabela de referências cruzadas, a pertença a object streams e a precedência de revisões, mas os headers publicados não expõem nenhum ponto de entrada que receba um número de objeto e devolva o seu offset bruto, a sua geração, que revisão venceu, ou em que ObjStm vive. O lado da gravação é igualmente fechado: FPDF_SaveAsCopy e FPDF_SaveWithVersion só lhe entregam um callback de escrita sequencial. Qualquer patch ao nível de bytes a um catálogo depois de uma gravação nativa tem por isso de ser construído na camada Pascal, e é por isso que o PDFiumPas analisa estas estruturas por si em vez de reutilizar a DLL

O que é que o índice esparso realmente mantém em memória?

Descritores, não entradas. Para uma tabela clássica (ISO 32000-1 §7.5.4) um TPdfSparseXrefSubsection armazena o primeiro número de objeto, a contagem de objetos, o offset em bytes onde as linhas de entradas começam e a largura de entrada medida. As entradas em si ficam no ficheiro. A largura é medida a partir da primeira linha em vez de se assumir 20 bytes, porque os produtores discordam sobre terminações de linha; o PDFiumPas aceita 18 a 64 e rejeita tudo o que esteja fora dessa banda, juntamente com qualquer subsecção cuja contagem declarada corresse para além do fim do stream. Para um cross-reference stream (§7.5.8) a secção guarda as três larguras de campos /W, cada uma limitada a 0 a 8, os pares /Index aplanados, e os bytes de entrada descodificados, cujo comprimento esperado é calculado a partir de /W e /Index antes de um único byte ser expandido

O índice inteiro é construído pelo Initialize a partir de uma janela final de no máximo 1 MiB, que é onde o startxref é encontrado, e cada leitura de objeto subsequente usa uma janela de objeto de 1 MiB. O teto do stream bruto é 64 MiB e uma única linha xref não pode exceder 1024 bytes. Se já leu a nossa nota sobre validar object streams e cross-reference streams com o PDFiumPas, a mesma disciplina de larguras de campo aplica-se aqui, só que agora é usada para endereçar uma entrada em vez de auditar uma tabela inteira

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { percorre apenas startxref, a cadeia /Prev e o catálogo }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Como é que uma consulta alcança um objeto?

Por aritmética, em ambos os layouts. Uma subsecção clássica tem linhas de largura fixa, pelo que o endereço de uma entrada é o início da subsecção mais o offset do objeto vezes a largura medida; o PDFiumPas lê depois essa única linha, analisa o offset de dez dígitos e a geração de cinco dígitos, verifica a geração contra o teto de 65535 do §7.5.4, e classifica a palavra-chave final como axkDirect ou axkFree. Um cross-reference stream precisa de um passo extra porque as subsecções /Index estão concatenadas na sequência de bytes descodificada, pelo que o índice acumula as contagens das subsecções precedentes antes de multiplicar pela largura /W somada. O tipo 1 produz um offset, o tipo 2 produz um número de object stream e um índice de membro, e qualquer outra coisa torna-se axkUnknown em vez de um palpite

{ tabela clássica, ISO 32000-1 secção 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ cross-reference stream, ISO 32000-1 secção 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Nada em nenhum dos caminhos é proporcional ao /Size. Esse é o sentido inteiro da reescrita: o valor de tamanho do trailer é transportado como metadados e usado ao escrever a revisão incremental, mas nunca conduz uma alocação. A suite de regressão fixa isto com um fixture cuja árvore de páginas vive nos objetos 1.000.000.000 e 1.000.000.001 sob um trailer que declara /Size 1000000002. A antiga implementação densa recusava esse ficheiro; o índice esparso resolve ambas as referências e preserva o tamanho declarado no trailer de saída

Como o PDFiumPas resolve um número de objeto em Delphi: uma tabela de referências cruzadas clássica multiplica a largura de linha medida, enquanto um cross-reference stream acumula as contagens das subsecções precedentes antes de multiplicar as larguras de campo somadas do array /W
Ambas as consultas são aritmética pura, pelo que nenhuma é proporcional à contagem de objetos declarada no trailer

Revisões híbridas, cadeias /Prev e as salvaguardas em torno delas

A precedência de revisões é onde um índice lazy ingénuo se engana. O PDFiumPas percorre a cadeia a partir do startxref por ordem do mais novo para o mais antigo e pára uma consulta na primeira secção que responde, o que reproduz a regra de precedência sem materializar uma tabela combinada. Ficheiros hybrid-reference (§7.5.8.4) são tratados dentro do ramo clássico: quando o trailer transporta um /XRefStm, a secção de stream suplementar é registada antes da secção clássica que a referenciou, pelo que objetos comprimidos invisíveis à tabela simples continuam a ser encontrados enquanto as entradas clássicas mantêm o seu estatuto. As revisões mais antigas são depois seguidas através de /Prev

Duas salvaguardas limitam essa travessia, e ambas importam em ficheiros danificados. Cada offset visitado é registado, pelo que um /Prev que aponte de volta para a cadeia termina em vez de girar, e a profundidade da travessia é limitada por MaxRecursionDepth, que vale 1024 por predefinição. A flag de encriptação é acumulada ao longo de toda a cadeia em vez de lida apenas do trailer mais recente, porque um documento cujo trailer mais recente omite /Encrypt ainda pode estar encriptado mais atrás; os chamadores que anexam revisões contam com essa flag para recusar escrever objetos em texto claro num ficheiro encriptado

Como o PDFiumPas percorre uma cadeia de revisões PDF híbrida em Delphi: as secções são registadas do mais novo primeiro a partir de startxref, uma secção XRefStm suplementar vai à frente da tabela clássica que a nomeou, e a travessia /Prev é limitada por offsets visitados e um teto de profundidade
Uma consulta pára na primeira secção que responde, o que reproduz a precedência de revisões sem jamais materializar uma tabela combinada

Entradas de tipo 2: porque é que o object stream espera

Uma entrada de tipo 2 nomeia um object stream, e o PDFiumPas não toca nesse stream até um chamador pedir um membro dele. Quando finalmente o faz, /Type /ObjStm é verificado, /N é conferido contra o orçamento de objetos e /First contra o teto de bytes descodificados, e /N é conferido de sanidade contra /First, visto que cada par de header precisa de pelo menos quatro bytes. Só então o stream é expandido, e a varredura do header pára no membro pedido e no seu sucessor em vez de construir uma tabela completa de membros. Um único object stream descodificado é retido de cada vez, que é a troca certa quando um ramo da árvore de páginas se agrupa num único ObjStm; o nosso artigo sobre descodificação de object streams e preditores em Delphi cobre o que acontece dentro desse passo de expansão (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { um único índice retido, muitas leituras conscientes da geração }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { o Source continua a ser seu }
  end;
end;

Onde a cache deixa de fazer promessas

O índice é um instantâneo, e vale a pena ser direto quanto a isso. As secções são analisadas uma vez no Initialize; se o stream subjacente for modificado depois, todas as entradas em cache ficam caducadas e a classe não dará por isso. O TPdfSparseDictionaryReader mantém o índice pelo tempo de vida da origem que pertence ao chamador, que é exatamente o que uma travessia recursiva de uma árvore de páginas quer e exatamente o que não deve fazer através de uma reescrita. A cache de entradas é um array plano pesquisado linearmente e também guarda resultados negativos, pelo que algumas centenas de consultas são baratas e algumas centenas de milhar não são. O ReadDictionary exige uma correspondência exata de geração enquanto o ReadLatestDictionary resolve a ativa, e a diferença é deliberada: a resolução de referências precisa do primeiro, a inspeção de catálogo precisa do segundo. Onde estes limites não podem ser honrados, as unidades em redor recaem no parser legado de ficheiro inteiro em vez de estreitar o conjunto de ficheiros que ainda funcionam, um padrão que também usamos para streaming a pedido de PDFs grandes

As regressões entre compiladores cobrem o mesmo comportamento nas três toolchains, incluindo uma asserção de que uma origem de 2 MiB nunca vê uma única leitura maior que 1 MiB. Se mantém código Delphi, C++Builder ou Lazarus que toca diretamente na estrutura PDF e está cansado de pagar custos de análise de ficheiro inteiro por quatro dicionários, o índice esparso e a costura pública em torno dele acompanham o PDFiumPas Delphi PDFium component