Você quer um dicionário de um PDF de 2 GB e a ferramenta primeiro expande a tabela de referência cruzada inteira em um array dimensionado pelo /Size do trailer. O PDFiumPas substitui essa etapa por um índice de objetos esparso e lazy: ele mantém apenas os descritores de seções xref, resolve um único número de objeto sob demanda por janelas limitadas e faz cache apenas das entradas que você realmente tocou
A forma antiga deste código no FPdfCompress era honesta, mas cara. O ApplyDefaultOpenAction lia o arquivo completo em um TBytes e depois alocava um array denso TPdfActiveXrefEntries com uma posição por número de objeto até o /Size. Duas coisas davam errado 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, então um arquivo 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
Por que a API pública do PDFium não responde a essa pergunta?
Porque a informação existe dentro do PDFium mas nunca cruza a fronteira C. O CPDF_Parser mantém internamente a tabela de referência cruzada, a participação nos 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 retorne seu offset bruto, sua geração, qual revisão venceu ou em qual ObjStm ele vive. O lado da gravação é igualmente fechado: FPDF_SaveAsCopy e FPDF_SaveWithVersion só entregam um callback de gravação sequencial. Qualquer patch em nível de bytes em um catálogo depois de uma gravação nativa, portanto, precisa ser construído na camada Pascal, e é por isso que o PDFiumPas analisa essas estruturas por conta própria em vez de reutilizar a DLL
O que o índice esparso realmente mantém na 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 arquivo. A largura é medida a partir da primeira linha em vez de assumida como 20 bytes, porque os produtores divergem sobre finais de linha; o PDFiumPas aceita de 18 a 64 e rejeita qualquer coisa fora dessa faixa, junto com qualquer subsection cuja contagem declarada passaria do fim do stream. Para um cross-reference stream (§7.5.8), a seção guarda as três larguras de campos do /W, cada uma limitada a 0 através de 8, os pares /Index achatados e os bytes de entrada decodificados, cujo comprimento esperado é calculado a partir de /W e /Index antes de um único byte ser inflado
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 toda leitura de objeto subsequente usa uma janela de objeto de 1 MiB. O teto de stream bruto é 64 MiB e uma única linha de xref não pode exceder 1024 bytes. Se você leu nossa nota sobre validar object e cross-reference streams com PDFiumPas, a mesma disciplina de largura de campos se aplica aqui, só que agora ela é 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 uma consulta chega a um objeto?
Por aritmética, nos dois layouts. Uma subsection clássica tem linhas de largura fixa, então o endereço de uma entrada é o início da subsection mais o offset do objeto vezes a largura medida; o PDFiumPas então lê essa única linha, interpreta o offset de dez dígitos e a geração de cinco dígitos, verifica a geração contra o teto de 65535 da §7.5.4 e classifica a palavra-chave final como axkDirect ou axkFree. Um cross-reference stream precisa de um passo a mais porque as subsections do /Index são concatenadas na sequência de bytes decodificada, então o índice acumula as contagens das subsections anteriores antes de multiplicar pela largura somada do /W. Tipo 1 rende um offset, tipo 2 rende um número de object stream e um índice de membro, e qualquer outra coisa vira axkUnknown em vez de um chute
{ tabela clássica, ISO 32000-1 seção 7.5.4 }
EntryOffset := Subsection.EntryOffset +
Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;
{ cross-reference stream, ISO 32000-1 seçã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 é todo o ponto da reescrita: o valor de tamanho do trailer é levado adiante como metadado e usado ao gravar a revisão incremental, mas nunca dirige uma alocação. A suíte de regressão fixa isso 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 arquivo; o índice esparso resolve as duas referências e preserva o tamanho declarado no trailer de saída
Revisões híbridas, cadeias /Prev e as guardas ao redor delas
A precedência de revisões é onde um índice lazy ingênuo erra. O PDFiumPas percorre a cadeia a partir do startxref em ordem do mais novo para o mais antigo e interrompe uma consulta na primeira seção que responde, o que reproduz a regra de precedência sem materializar uma tabela mesclada. Arquivos de referência híbrida (§7.5.8.4) são tratados dentro do ramo clássico: quando o trailer traz um /XRefStm, a seção de stream suplementar é registrada antes da seção clássica que a referenciou, então objetos comprimidos invisíveis à tabela simples ainda são encontrados enquanto as entradas clássicas mantêm sua posição. As revisões mais antigas são então seguidas pelo /Prev
Duas guardas limitam esse percurso, e ambas importam em arquivos danificados. Todo offset visitado é registrado, então um /Prev apontando de volta para dentro da cadeia termina em vez de girar, e a profundidade de travessia é limitada por MaxRecursionDepth, cujo padrão é 1024. O flag de criptografia é acumulado pela cadeia inteira em vez de lido apenas do trailer mais novo, porque um documento cujo trailer mais recente omite o /Encrypt ainda pode estar criptografado mais atrás; chamadores que anexam revisões dependem desse flag para se recusar a gravar objetos em texto plano em um arquivo criptografado
Entradas tipo 2: por que o object stream espera
Uma entrada tipo 2 nomeia um object stream, e o PDFiumPas não toca nesse stream até que um chamador peça um membro dele. Quando finalmente o faz, o /Type /ObjStm é verificado, o /N é conferido contra o orçamento de objetos e o /First contra o teto de bytes decodificados, e o /N passa por uma verificação de sanidade contra o /First, já que cada par de header precisa de pelo menos quatro bytes. Só então o stream é inflado, e a varredura de header para no membro solicitado e em seu sucessor em vez de construir uma tabela completa de membros. Um object stream decodificado é retido por vez, que é a troca certa quando um ramo da árvore de páginas se agrupa em um único ObjStm; nosso artigo sobre decodificação de object stream e predictor em Delphi cobre o que acontece dentro dessa etapa de inflar (§7.5.7)
var
Reader: TPdfSparseDictionaryReader;
Generation: Integer;
Dict: AnsiString;
begin
{ um índice retido, muitas leituras cientes de 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 seu }
end;
end;
Onde o cache para de fazer promessas
O índice é um snapshot, e vale ser direto quanto a isso. As seções são analisadas uma vez no Initialize; se o stream subjacente for modificado depois, toda entrada em cache fica obsoleta e a classe não vai notar. O TPdfSparseDictionaryReader mantém o índice pelo tempo de vida da fonte, que pertence ao chamador, que é exatamente o que uma travessia recursiva de uma árvore de páginas quer e exatamente o que você não deve fazer através de uma reescrita. O cache de entradas é um array plano pesquisado linearmente e também armazena resultados negativos, então algumas centenas de consultas são baratas e algumas centenas de milhares 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 esses limites não podem ser honrados, as unidades vizinhas recuam para o parser legado de arquivo inteiro em vez de restringir o conjunto de arquivos que ainda funcionam, um padrão que também usamos para streaming de PDFs grandes sob demanda
Regressões entre compiladores cobrem o mesmo comportamento nas três toolchains, incluindo uma asserção de que uma fonte de 2 MiB nunca vê uma única leitura maior que 1 MiB. Se você mantém código Delphi, C++Builder ou Lazarus que toca a estrutura de PDF diretamente e está cansado de pagar custos de análise do arquivo inteiro por quatro dicionários, o índice esparso e a interface pública ao redor dele estão na componente PDFium PDFiumPas para Delphi