Artículo técnico

Edición de name trees y number trees PDF en Delphi

Parchea un arreglo de hoja para agregar un nombre y la entrada igual puede leerse como inexistente. PDFium Component responde a eso con TPdfTreeEditor, un editor general para name trees y number trees de ISO 32000: carga el árbol con validación estricta, edita las entradas en memoria, reconstruye un conjunto de nodos balanceado con /Limits correctos y escribe una revisión incremental verificada

El fallo que motivó esta unidad es del tipo que solo se diagnostica leyendo bytes. Una herramienta agrega una clave y un valor a un arreglo /Names de hoja, escribe una revisión nueva, y cada hex dump confirma que la clave está en el archivo. Acrobat sigue diciendo que el adjunto no existe. Nada está corrupto en el sentido ingenuo. El árbol simplemente ya no es un árbol por el que el algoritmo de búsqueda pueda descender

¿Por qué un arreglo de hoja parcheado rompe la búsqueda?

Porque la búsqueda es un descenso binario a través de /Limits, no un escaneo de las hojas. ISO 32000-1 §7.9.6 define un name tree como una raíz cuyos /Kids bajan hasta hojas que contienen /Names, un arreglo de clave y valor alternados, con las claves ordenadas por comparación de bytes en todo el árbol, y cada nodo no raíz declarando /Limits como su clave menor y mayor propias. Un lector conforme compara la clave buscada contra el rango de cada kid y abre exactamente un hijo. Si agregas una clave a una hoja y dejas los /Limits viejos intactos, el descenso pasa de largo por el nodo que realmente la contiene

Diagrama de la búsqueda en name trees PDF de PDFium Component: el lector desciende de forma binaria por los rangos /Limits de los kids por comparación de bytes, así que una clave como invoice-2026-09 agregada a una hoja cuyos /Limits desactualizados todavía terminan en (f) nunca se abre, el descenso toma el kid (g) (z) en su lugar, y el adjunto se lee como inexistente pese a estar en el archivo
La búsqueda abre exactamente un hijo comparando la clave buscada contra el rango de cada kid, así que una hoja que ganó una clave sin refrescar /Limits se recorre directamente de largo

Los number trees (§7.9.7) tienen la misma forma con /Nums y claves enteras, y llevan tráfico real: /PageLabels en §12.4.2 es un number tree, y las categorías /Names del catálogo de §7.7.4 te dan /Dests, /EmbeddedFiles y /JavaScript como name trees. Así que cuatro invariantes tienen que cumplirse juntas, no una a la vez. Las claves deben crecer estrictamente en el orden del árbol, cada nivel debe publicar límites que coincidan con su rango real de claves, los rangos entre hermanos no deben solaparse, y el número de objeto y la generación de la raíz deben quedarse exactamente donde estaban, porque el catálogo y cualquier otro referenciador apuntan a esa raíz por referencia indirecta. Nuestra nota sobre trabajar con adjuntos PDF en Delphi muestra el lado consumidor de la misma estructura

¿Qué se niega a cargar TPdfTreeEditor?

Más de lo que esperarías, y a propósito. TPdfTreeEditor.TryLoad recorre el grafo de nodos a través de TPdfSparseDictionaryReader, resolviendo cada hijo con ReadDictionary en la generación exacta que nombra la referencia en lugar de aceptar la revisión que esté activa. Un árbol que no se pueda probar bien formado se rechaza antes de permitir una sola edición, lo que evita que una entrada rota se convierta en una salida rota de apariencia plausible

  • Ciclos y referencias repetidas, ambos reportados como ptviCycle, así que una entrada /Kids que apunta hacia arriba en la cadena termina en lugar de recursar
  • Formas de nodo mezcladas: un diccionario que lleva /Kids junto con /Names o /Nums es ptviInvalidNodeShape, igual que una hoja no raíz vacía
  • Claves desordenadas o duplicadas, reportadas como ptviUnsortedKey y ptviDuplicateKey, verificadas en todo el recorrido en lugar de dentro de una sola hoja
  • Limits que no coinciden con las claves realmente presentes, reportados como ptviInvalidLimits
  • Un valor crudo que no es exactamente un objeto PDF completo, reportado como ptviInvalidValue
  • Excesos de presupuesto: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit y ptviDeadlineExceeded, acotados tanto por TPdfTreeEditOptions como por el presupuesto central del parser

TPdfTreeEditOptions.Default pone esos límites locales del árbol en 1,000,000 de entradas, 100,000 nodos, profundidad 64, 64 entradas por hoja y 32 kids por nodo. Las cifras de hoja y kid no son solo guardas; son el fan-out que se usa después cuando se reconstruye el árbol, que es por lo que el record valida que ambas sean al menos dos

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
    { el valor son los bytes en crudo de un 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 después del guardado de abajo }
  end;
end;

Los valores de las entradas se quedan como bytes crudos de objeto PDF

Un valor de entrada se almacena textualmente, y esa es la única decisión de diseño que hace seguro apuntar el editor a archivos que no produjiste tú. TPdfTreeEntry tiene NameKey, NumberKey y RawValue, y RawValue es la corrida de bytes cruda de un objeto PDF completo: un diccionario, un arreglo, una cadena, una referencia a stream o una referencia indirecta pelada. Nada se decodifica a un variant de Delphi, a un TStrings o a un record tipado. La recompensa es un round trip sin pérdida de valores sobre los que la unidad no sabe nada. Un /Filespec con una extensión de vendor, un arreglo de destino con un tipo de fit inusual, un diccionario de acción /JavaScript con claves privadas, todos sobreviven una reconstrucción byte por byte, porque el editor nunca tuvo opinión sobre su contenido. También encoge la API de modificación a algo demostrable: UpsertName y UpsertNumber solo tienen que verificar que el valor suministrado se parsea como exactamente un objeto completo, y RemoveName, RemoveNumber y Clear solo tocan la lista de entradas

¿Cómo se reconstruye un árbol balanceado sin renumerar la raíz?

En dos pasadas con un punto fijo. BuildBalancedObjects primero corta la lista de entradas ordenadas en hojas de a lo más MaxLeafEntries, luego pliega cada nivel en padres de a lo más MaxKidsPerNode y repite hasta que quede un solo nodo. Los limits se calculan de abajo hacia arriba, una hoja tomando su primera y última clave, un nodo interno tomando el límite inferior de su primer hijo y el superior de su último, así que los rangos solapados entre hermanos no pueden ocurrir por construcción

El punto fijo es la identidad. La raíz final siempre recibe el número de objeto y la generación con los que se cargó el árbol, así que la referencia del catálogo sigue siendo válida y nada más del documento necesita reescribirse. Cualquier otro nodo reutiliza la referencia de un nodo cargado previamente solo cuando el tipo de nodo coincide y el rango completo de claves coincide, y en caso contrario toma un número fresco asignado desde el número de objeto máximo activo más uno. Esa combinación mantiene los subárboles sin cambios apuntando a sus objetos existentes y a la vez garantiza que cualquier nodo cuyo contenido se movió se escriba como objeto nuevo en lugar de reinterpretarse en silencio. La misma disciplina de reutilización de referencias impulsa el editor de outline y su re-mapeo de páginas en la unidad hermana

Diagrama de la reconstrucción de TPdfTreeEditor en PDFium Component: BuildBalancedObjects corta la lista de entradas ordenadas en hojas de a lo más MaxLeafEntries claves, pliega cada nivel en padres de a lo más MaxKidsPerNode, calcula /Limits de abajo hacia arriba desde las primeras y últimas claves, y le da a la raíz terminada el número de objeto y la generación exactos con los que se cargó el árbol
Las hojas toman sus limits de su primera y última clave, los nodos internos de sus hijos más externos, y la raíz final conserva el número de objeto y la generación con los que se cargó el árbol
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber proviene de la revisión de origen }
  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;

Escribe la revisión y luego vuelve a leer el resultado

Un guardado solo termina cuando la salida se ha vuelto a abrir y comparar. SaveIncremental lee los datos de la revisión fuente, rechaza una raíz que sea el propio catálogo del documento, serializa el conjunto de nodos balanceado más el objeto del catálogo, y se lo entrega todo a AppendPdfSparseUpdate, que agrega una revisión nueva según §7.5.6 preservando la referencia /Info existente y el arreglo /ID del documento

Luego viene la parte que vale la pena copiar en tus propios writers. El editor vuelve a llamar a TPdfTreeEditor.TryLoad, esta vez contra el stream de destino, con la misma referencia de raíz y las mismas opciones, y compara el conteo de entradas recargado y cada clave y RawValue contra lo que pretendía escribir. Si la recarga falla o alguna comparación difiere, el stream de destino se resetea en lugar de devolverse, así que una revisión parcialmente escrita nunca se entrega como éxito. La verificación lee la salida por el mismo camino sparse acotado descrito en nuestro artículo sobre el índice sparse perezoso de objetos PDF, que es lo que mantiene la comprobación accesible en archivos grandes

Diagrama del guardado incremental verificado en PDFium Component: SaveIncremental serializa el conjunto de nodos balanceado, AppendPdfSparseUpdate agrega una revisión nueva preservando /Info y el arreglo /ID del documento, luego TPdfTreeEditor.TryLoad vuelve a leer el stream de destino y compara el conteo de entradas y cada clave y RawValue, reseteando el stream en lugar de devolver una revisión a medio escribir
El guardado solo termina cuando la salida se ha vuelto a abrir por el mismo camino acotado y se ha comparado entrada por entrada, y una comparación fallida resetea el 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 ya reiniciado, no se conservó nada a medio escribir }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Las fuentes cifradas se rechazan, y eso es honesto

La implementación actual rechaza de plano una fuente cifrada. La razón es específica, no defensiva: una reconstrucción de árbol introduce objetos de cadena nuevos y puede introducir referencias a stream nuevas, y §7.6 exige que esos se cifren por objeto con la clave del documento y el número de objeto y la generación dueños. Copiar el trailer /Encrypt hacia adelante mientras se agregan cadenas en texto plano produciría un archivo que abre y luego se lee como basura exactamente donde están tus claves nuevas, que es peor que un rechazo claro. Dos límites más merecen decirse con claridad. El editor trabaja sobre una instantánea del stream fuente, así que la fuente no debe modificarse mientras un editor esté vivo sobre ella, y se niega a tratar el catálogo del documento como raíz de árbol, porque el catálogo es un diccionario que por casualidad contiene árboles, no un nodo de árbol en sí. Si mantienes código en Delphi, C++Builder o Lazarus que escriba /EmbeddedFiles, /Dests, /JavaScript o /PageLabels y has estado parcheando arreglos de hoja a mano, el editor de árboles y su guardado incremental verificado viajan en el componente PDFiumPas Delphi PDFium