Artigo Técnico

Editar Name Trees e Number Trees de PDF em Delphi

Remende um array de folha para adicionar um nome e a entrada ainda pode ler-se como ausente. O PDFium Component responde com o TPdfTreeEditor, um editor geral para name trees e number trees da ISO 32000: carrega a árvore com validação estrita, edita as entradas em memória, reconstrói um conjunto de nós balanceado com /Limits corretos e escreve uma revisão incremental verificada

A falha que motivou esta unit é do tipo que só se diagnostica a ler bytes. Uma ferramenta anexa uma chave e um valor a um array /Names de uma folha, escreve uma revisão nova, e cada hex dump confirma que a chave está no ficheiro. O Acrobat continua a dizer que o anexo não existe. Nada está corrompido no sentido ingénuo. A árvore simplesmente já não é uma árvore que o algoritmo de lookup consiga descer

Porque é que um array de folha remendado parte o lookup?

Porque o lookup é uma descida binária através de /Limits, não uma varredura das folhas. A ISO 32000-1 §7.9.6 define uma name tree como uma raiz cujos /Kids descem até folhas que guardam /Names, um array de chave e valor alternados, com as chaves ordenadas por comparação de bytes em toda a árvore, e cada nó não-raiz a declarar /Limits como a sua própria chave mínima e máxima. Um reader conforme compara a chave pretendida com cada intervalo de kid e abre exatamente um filho. Anexe uma chave a uma folha, deixe o /Limits antigo intocado, e a descida passa direto pelo nó que realmente a guarda

Diagrama do lookup de name tree de PDF no PDFium Component: o reader desce em binário pelos intervalos de /Limits dos kids por comparação de bytes, pelo que uma chave como invoice-2026-09 anexada a uma folha cujo /Limits stale ainda acaba em (f) nunca é aberta, a descida toma o kid (g) (z) em vez disso, e o anexo lê-se como ausente apesar de estar no ficheiro
O lookup abre exatamente um filho ao comparar a chave pretendida com cada intervalo de kid, pelo que uma folha que ganhou uma chave sem refrescar /Limits é descida direto para lá

As number trees (§7.9.7) têm a mesma forma com /Nums e chaves inteiras, e carregam tráfego real: o /PageLabels em §12.4.2 é uma number tree, e as categorias de /Names do catálogo de §7.7.4 dão-lhe /Dests, /EmbeddedFiles e /JavaScript como name trees. Assim, quatro invariantes têm de se aguentar em conjunto, não uma de cada vez. As chaves têm de estar estritamente crescentes em ordem de árvore, cada nível tem de publicar limites que batam certo com o seu intervalo real de chaves, os intervalos de irmãos não se podem sobrepor, e o object number e a geração da raiz têm de ficar exatamente onde estavam, porque o catálogo e qualquer outro referenciador apontam para essa raiz por referência indireta. A nossa nota sobre trabalhar com anexos de PDF em Delphi mostra o lado consumidor da mesma estrutura

O que é que o TPdfTreeEditor recusa carregar?

Mais do que pode esperar, e de propósito. O TPdfTreeEditor.TryLoad percorre o grafo de nós através do TPdfSparseDictionaryReader, resolvendo cada filho com ReadDictionary na geração exata nomeada pela referência em vez de aceitar a revisão que estiver ativa. Uma árvore que não possa ser provada bem formada é rejeitada antes de uma única edição ser permitida, o que impede um input partido de se tornar um output partido com ar plausível

  • Ciclos e referências repetidas, ambos reportados como ptviCycle, pelo que uma entrada /Kids a apontar de volta pela cadeia termina em vez de recursar
  • Formas de nó misturadas: um dicionário que carrega /Kids juntamente com /Names ou /Nums é ptviInvalidNodeShape, tal como uma folha não-raiz vazia
  • Chaves desordenadas ou duplicadas, reportadas como ptviUnsortedKey e ptviDuplicateKey, verificadas ao longo de todo o percurso em vez de dentro de uma folha
  • Limits que discordam das chaves realmente presentes, reportados como ptviInvalidLimits
  • Um valor cru que não seja exatamente um objeto PDF completo, reportado como ptviInvalidValue
  • Estouros de budget: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit e ptviDeadlineExceeded, limitados tanto pelo TPdfTreeEditOptions como pelo budget central do parser

O TPdfTreeEditOptions.Default define esses limites locais à árvore em 1.000.000 de entradas, 100.000 nós, profundidade 64, 64 entradas por folha e 32 kids por nó. Os valores de folha e kid não são só guards; são o fan-out usado mais tarde quando a árvore é reconstruída, que é a razão pela qual o record valida que ambos são pelo menos dois

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { catálogo /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { o valor são os bytes crus de um objeto PDF completo }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free depois do save abaixo }
  end;
end;

Os valores de entrada ficam como bytes crus de objeto PDF

Um valor de entrada é guardado verbatim, e esta é a única decisão de design que torna o editor seguro de apontar a ficheiros que não produziu. O TPdfTreeEntry guarda NameKey, NumberKey e RawValue, e o RawValue é a sequência de bytes crus de um objeto PDF completo: um dicionário, um array, uma string, uma referência a stream ou uma referência indireta nua. Nada é descodificado num variant de Delphi, num TStrings ou num record tipado. O payoff é um round-trip lossless de valores que a unit não conhece. Um /Filespec com uma extensão de vendor, um array de destinos com um fit type invulgar, um dicionário de ação /JavaScript com chaves privadas, tudo sobrevive a um rebuild byte a byte, porque o editor nunca teve opinião sobre os conteúdos. Também encolhe a API de modificação para algo provável: o UpsertName e o UpsertNumber só têm de verificar que o valor fornecido se analisa como exatamente um objeto completo, e o RemoveName, o RemoveNumber e o Clear só tocam na lista de entradas

Como é que uma árvore balanceada é reconstruída sem renumerar a raiz?

Em duas passadas com um ponto fixo. O BuildBalancedObjects primeiro corta a lista ordenada de entradas em folhas de no máximo MaxLeafEntries, depois dobra cada nível em pais de no máximo MaxKidsPerNode e repete até sobrar um único nó. Os Limits são calculados de baixo para cima, uma folha a tomar a sua primeira e última chave, um nó interno a tomar o limite inferior do seu primeiro filho e o limite superior do último, pelo que intervalos sobrepostos entre irmãos não podem ocorrer por construção

O ponto fixo é a identidade. A raiz final recebe sempre o object number e a geração com que a árvore foi carregada, pelo que a referência do catálogo se mantém válida e nada mais no documento precisa de ser reescrito. Cada outro nó reutiliza a referência de um nó previamente carregado apenas quando o tipo de nó coincide e o intervalo completo de chaves coincide, e de outro modo toma um número novo alocado a partir do object number máximo ativo mais um. Essa combinação mantém subárvores inalteradas a apontar para os seus objetos existentes, garantindo ao mesmo tempo que qualquer nó cujo conteúdo se mexeu é escrito como objeto novo em vez de re-interpretado em silêncio. A mesma disciplina de reutilização de referências conduz o editor de outlines e o seu remap de páginas na unit irmã

Diagrama do rebuild do TPdfTreeEditor no PDFium Component: o BuildBalancedObjects corta a lista ordenada de entradas em folhas de no máximo MaxLeafEntries chaves, dobra cada nível em pais de no máximo MaxKidsPerNode, calcula os /Limits de baixo para cima a partir das primeiras e últimas chaves, e dá à raiz acabada o object number e a geração exatos com que a árvore foi carregada
As folhas tomam os seus limites das primeiras e últimas chaves, os nós internos dos seus filhos mais exteriores, e a raiz final mantém o object number e a geração com que a árvore foi carregada
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber vem da revisão de origem }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;

Escreva a revisão e depois leia o resultado de volta

Um save só está acabado depois de o output ter sido reaberto e comparado. O SaveIncremental lê os factos da revisão de origem, recusa uma raiz que seja o próprio catálogo do documento, serializa o conjunto balanceado de nós mais o objeto do catálogo, e entrega tudo ao AppendPdfSparseUpdate, que anexa uma revisão nova segundo §7.5.6 preservando a referência /Info existente e o array /ID do documento

Depois vem a parte que vale a pena copiar para os seus próprios writers. O editor chama o TPdfTreeEditor.TryLoad de novo, desta vez contra o stream de destino, com a mesma referência de raiz e as mesmas opções, e compara a contagem de entradas recarregada e cada chave e RawValue com o que pretendia escrever. Se o reload falhar ou alguma comparação diferir, o stream de destino é reiniciado em vez de devolvido, pelo que uma revisão parcialmente escrita nunca é entregue como sucesso. A verificação lê o output pelo mesmo path sparse com limites descrito no nosso artigo sobre o índice de objetos PDF sparse e lazy, que é o que mantém a verificação acessível em ficheiros grandes

Diagrama do save incremental verificado no PDFium Component: o SaveIncremental serializa o conjunto balanceado de nós, o AppendPdfSparseUpdate anexa uma revisão nova preservando /Info e o array /ID do documento, depois o TPdfTreeEditor.TryLoad relê o stream de destino e compara a contagem de entradas e cada chave e RawValue, reiniciando o stream em vez de devolver uma revisão a meio escrita
O save só está acabado depois de o output ter sido reaberto pelo mesmo path com limites e comparado entrada a entrada, e uma comparação falhada reinicia o stream de destino
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { destino já reiniciado, nada de meio escrito foi mantido }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Fontes encriptadas são recusadas, e isso é honesto

A implementação atual rejeita uma fonte encriptada sem rodeios. A razão é específica e não defensiva: um rebuild de árvore introduz objetos de string novos e pode introduzir referências a streams novas, e a §7.6 exige que esses sejam encriptados por objeto com a chave do documento e o object number e a geração donos. Copiar o trailer /Encrypt para a frente enquanto anexa strings em plaintext produziria um ficheiro que abre e depois se lê como lixo exatamente onde as suas chaves novas estão, que é pior do que uma recusa clara. Mais dois limites valem a pena ser ditos claramente. O editor trabalha sobre um snapshot do stream de origem, pelo que a origem não pode ser modificada enquanto um editor estiver vivo sobre ela, e recusa tratar o catálogo do documento como raiz de árvore, já que o catálogo é um dicionário que por acaso contém árvores e não um nó de árvore em si. Se mantém código Delphi, C++Builder ou Lazarus que escreve /EmbeddedFiles, /Dests, /JavaScript ou /PageLabels e tem remendado arrays de folha à mão, o editor de árvores e o seu save incremental verificado vêm no componente PDFiumPas Delphi PDFium