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
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
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
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