Articolo tecnico

Modifica di name tree e number tree PDF in Delphi

Fai una patch all'array di una foglia per aggiungere un nome e la voce può comunque risultare assente. La PDFium Component risponde con TPdfTreeEditor, un editor generico per i name tree e i number tree di ISO 32000: carica l'albero con validazione rigorosa, modifica le voci in memoria, ricostruisce un set di nodi bilanciato con i /Limits corretti e scrive una revisione incrementale verificata

Il fallimento che ha motivato questa unit è del tipo che diagnostichi solo leggendo i byte. Uno strumento append una chiave e un valore a un array /Names di una foglia, scrive una nuova revisione, e ogni hex dump conferma che la chiave è nel file. Acrobat continua a dire che l'allegato non esiste. Nulla è corrotto nel senso naif. L'albero semplicemente non è più un albero che l'algoritmo di lookup può scendere

Perché un array di foglia patchato rompe il lookup?

Perché il lookup è una discesa binaria attraverso i /Limits, non una scansione delle foglie. ISO 32000-1 §7.9.6 definisce un name tree come una radice i cui /Kids portano giù alle foglie che tengono i /Names, un array di chiave e valore alternati, con le chiavi ordinate per confronto di byte su tutto l'albero, e ogni nodo non radice che dichiara i /Limits come la propria chiave minima e massima. Un reader conforme confronta la chiave cercata con ogni intervallo dei kid e apre esattamente un figlio. Append una chiave a una foglia, lascia i vecchi /Limits intatti e la discesa passa dritto davanti al nodo che in realtà la contiene

Diagramma del lookup di un name tree PDF nella PDFium Component: il reader scende in binario attraverso gli intervalli /Limits dei kid per confronto di byte, quindi una chiave come invoice-2026-09 appesa a una foglia i cui /Limits stantii finiscono ancora a (f) non viene mai aperta, la discesa prende il kid (g) (z), e l'allegato risulta assente pur essendo nel file
Il lookup apre esattamente un figlio confrontando la chiave cercata con ogni intervallo dei kid, quindi una foglia che ha guadagnato una chiave senza aggiornare i /Limits viene superata in discesa senza essere aperta

I number tree (§7.9.7) hanno la stessa forma con i /Nums e chiavi intere, e portano traffico vero: i /PageLabels di §12.4.2 sono un number tree, e le categorie /Names del catalogo di §7.7.4 ti danno /Dests, /EmbeddedFiles e /JavaScript come name tree. Quindi quattro invarianti devono reggere insieme, non uno alla volta. Le chiavi devono essere strettamente crescenti nell'ordine dell'albero, ogni livello deve pubblicare dei limiti che combaciano col suo intervallo di chiavi reale, gli intervalli tra fratelli non devono sovrapporsi, e l'object number e la generazione della radice devono restare esattamente dove erano, perché il catalogo e ogni altro referenziatore puntano a quella radice con riferimento indiretto. La nostra nota su lavorare con gli allegati PDF in Delphi mostra il lato consumer della stessa struttura

Cosa si rifiuta di caricare TPdfTreeEditor?

Più di quanto ti aspetteresti, e di proposito. TPdfTreeEditor.TryLoad cammina sul grafo dei nodi tramite TPdfSparseDictionaryReader, risolvendo ogni figlio con ReadDictionary alla generazione esatta indicata dal riferimento invece di accettare qualunque revisione sia attiva. Un albero di cui non si può dimostrare la buona formazione viene rifiutato prima che sia permessa una singola modifica, così un input rotto non diventa un output rotto dall'aria plausibile

  • Cicli e riferimenti ripetuti, entrambi segnalati come ptviCycle, così una voce /Kids che punta indietro su per la catena termina invece di ricorrere
  • Forme di nodo miste: un dizionario che porta /Kids insieme a /Names o /Nums è ptviInvalidNodeShape, come una foglia non radice vuota
  • Chiavi non ordinate o duplicate, segnalate come ptviUnsortedKey e ptviDuplicateKey, controllate su tutta la traversata e non dentro una singola foglia
  • Limits che non concordano con le chiavi davvero presenti, segnalati come ptviInvalidLimits
  • Un valore grezzo che non è esattamente un oggetto PDF completo, segnalato come ptviInvalidValue
  • Superamenti del budget: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit e ptviDeadlineExceeded, delimitati sia da TPdfTreeEditOptions sia dal budget centrale del parser

TPdfTreeEditOptions.Default imposta quei limiti locali dell'albero a 1.000.000 di voci, 100.000 nodi, profondità 64, 64 voci per foglia e 32 kid per nodo. I valori di foglia e kid non sono solo guardie; sono il fan-out usato più avanti quando l'albero viene ricostruito, ed è per questo che il record valida che entrambi siano almeno due

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { catalog /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
    { il valore sono i byte grezzi di un intero oggetto PDF }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free dopo il salvataggio qui sotto }
  end;
end;

I valori delle voci restano byte grezzi di oggetto PDF

Un valore di voce è memorizzato alla lettera, ed è questa la singola decisione di design che rende l'editor sicuro da puntare su file che non hai prodotto tu. TPdfTreeEntry tiene NameKey, NumberKey e RawValue, e RawValue è la sequenza di byte grezzi di un oggetto PDF completo: un dizionario, un array, una stringa, un riferimento a stream o un riferimento indiretto nudo. Nulla viene decodificato in un variant Delphi, in un TStrings o in un record tipizzato. Il tornaconto è il round-trip senza perdite di valori di cui l'unit non sa nulla. Un /Filespec con un'estensione del vendor, un array di destinazione con un fit type insolito, un dizionario di azione /JavaScript con chiavi private: tutto sopravvive a una ricostruzione byte per byte, perché l'editor non ha mai avuto un'opinione sui loro contenuti. Inoltre restringe l'API di modifica a qualcosa di dimostrabile: UpsertName e UpsertNumber devono solo verificare che il valore fornito si parsi come esattamente un oggetto completo, e RemoveName, RemoveNumber e Clear toccano solo la lista delle voci

Come si ricostruisce un albero bilanciato senza rinumerare la radice?

In due passate con un punto fisso. BuildBalancedObjects prima taglia la lista di voci ordinata in foglie di al massimo MaxLeafEntries, poi piega ogni livello in genitori di al massimo MaxKidsPerNode e ripete finché resta un nodo solo. I Limits sono calcolati dal basso verso l'alto, con una foglia che prende la sua prima e ultima chiave e un nodo interno che prende il limite inferiore del suo primo figlio e il limite superiore dell'ultimo, così gli intervalli tra fratelli non possono sovrapporsi per costruzione

Il punto fisso è l'identità. La radice finale riceve sempre l'object number e la generazione con cui l'albero è stato caricato, quindi il riferimento del catalogo resta valido e nient'altro nel documento va riscritto. Ogni altro nodo riusa il riferimento di un nodo caricato prima solo quando il tipo di nodo combacia e l'intero intervallo di chiavi combacia, altrimenti prende un numero nuovo allocato a partire dall'object number massimo attivo più uno. Questa combinazione tiene i sottoalberi invariati a puntare ai loro oggetti esistenti, garantendo al tempo stesso che ogni nodo il cui contenuto si è spostato venga scritto come oggetto nuovo invece che reinterpretato in silenzio. La stessa disciplina di riuso dei riferimenti guida l'outline editor e il suo page remapping nell'unit sorella

Diagramma della ricostruzione di TPdfTreeEditor nella PDFium Component: BuildBalancedObjects taglia la lista di voci ordinata in foglie di al massimo MaxLeafEntries chiavi, piega ogni livello in genitori di al massimo MaxKidsPerNode, calcola i /Limits dal basso da prime e ultime chiavi, e dà alla radice finita l'esatto object number e la generazione con cui l'albero è stato caricato
Le foglie prendono i loro limiti dalla prima e ultima chiave, i nodi interni dai loro figli più esterni, e la radice finale tiene l'object number e la generazione con cui l'albero è stato caricato
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber proviene dalla revisione sorgente }
  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;

Scrivi la revisione, poi rileggi il risultato

Un salvataggio è finito solo quando l'output è stato riaperto e confrontato. SaveIncremental legge i fatti della revisione sorgente, rifiuta una radice che è il catalogo del documento stesso, serializza il set di nodi bilanciato più l'oggetto catalogo, e consegna tutto a AppendPdfSparseUpdate, che append una nuova revisione secondo §7.5.6 preservando il riferimento /Info esistente e l'array /ID del documento

Poi arriva la parte che vale la pena copiare nei tuoi writer. L'editor chiama di nuovo TPdfTreeEditor.TryLoad, stavolta sullo stream di destinazione, con lo stesso riferimento di radice e le stesse opzioni, e confronta il conteggio delle voci ricaricato e ogni chiave e RawValue con ciò che intendeva scrivere. Se il reload fallisce o un confronto differisce, lo stream di destinazione viene resettato invece di essere restituito, così una revisione scritta a metà non viene mai consegnata come successo. La verifica legge l'output attraverso lo stesso percorso sparse limitato descritto nel nostro articolo sull'indice sparse lazy degli oggetti PDF, ed è questo che tiene il controllo a buon mercato sui file grandi

Diagramma del salvataggio incrementale verificato nella PDFium Component: SaveIncremental serializza il set di nodi bilanciato, AppendPdfSparseUpdate append una nuova revisione preservando /Info e l'array /ID del documento, poi TPdfTreeEditor.TryLoad rilegge lo stream di destinazione e confronta il conteggio delle voci e ogni chiave e RawValue, resettando lo stream invece di restituire una revisione scritta a metà
Il salvataggio è finito solo quando l'output è stato riaperto attraverso lo stesso percorso limitato e confrontato voce per voce, e un confronto fallito resetta lo stream di destinazione
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
      { destinazione già azzerata, nulla di scritto a metà è stato conservato }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Le sorgenti criptate vengono rifiutate, ed è onesto

L'implementazione attuale rifiuta a priori una sorgente criptata. Il motivo è specifico, non difensivo: una ricostruzione dell'albero introduce nuovi oggetti stringa e può introdurre nuovi riferimenti a stream, e §7.6 richiede che siano criptati per oggetto con la chiave del documento e l'object number e la generazione proprietari. Copiare avanti il trailer /Encrypt appendendo stringhe in chiaro produrrebbe un file che si apre e poi legge spazzatura esattamente dove stanno le tue nuove chiavi, il che è peggio di un rifiuto netto. Altri due limiti vale la pena dirli chiaro. L'editor lavora su uno snapshot dello stream sorgente, quindi la sorgente non va modificata finché un editor è vivo sopra di essa, e si rifiuta di trattare il catalogo del documento come radice dell'albero, perché il catalogo è un dizionario che per caso contiene alberi, non un nodo d'albero esso stesso. Se mantieni codice Delphi, C++Builder o Lazarus che scrive /EmbeddedFiles, /Dests, /JavaScript o /PageLabels e hai patchato array di foglie a mano, l'editor di alberi e il suo salvataggio incrementale verificato sono inclusi nella PDFiumPas Delphi PDFium component