Parchea un array de hoja para añadir un nombre y la entrada puede seguir leyéndose como inexistente. PDFium Component responde a eso con TPdfTreeEditor, un editor general de 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 equilibrado con /Limits correctos y escribe una revisión incremental verificada
El fallo que motivó esta unit es del tipo que solo diagnosticas leyendo bytes. Una herramienta añade una clave y un valor a un array /Names de hoja, escribe una revisión nueva, y todos los volcados hexadecimales confirman 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 array 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 array 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 propia clave menor y mayor. Un lector conforme compara la clave buscada con cada rango de hijo y abre exactamente un hijo. Añade una clave a una hoja y deja los /Limits viejos intactos, y el descenso pasa de largo frente al nodo que de verdad la contiene
Los number trees (§7.9.7) tienen la misma forma con /Nums y claves enteras, y cargan 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 sostenerse juntas, no una a la vez. Las claves deben ser estrictamente crecientes en orden de árbol, cada nivel debe publicar límites que coincidan con su rango real de claves, los rangos de 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 de 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 puede probarse 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 aspecto verosímil
- Ciclos y referencias repetidas, ambos reportados como
ptviCycle, así que una entrada/Kidsque apunta hacia arriba en la cadena termina en lugar de recursar - Formas de nodo mezcladas: un diccionario que lleva
/Kidsjunto con/Nameso/NumsesptviInvalidNodeShape, igual que una hoja no raíz vacía - Claves desordenadas o duplicadas, reportadas como
ptviUnsortedKeyyptviDuplicateKey, comprobadas en todo el recorrido y no 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,ptviEntryLimityptviDeadlineExceeded, acotados porTPdfTreeEditOptionsy por el presupuesto central del parser
TPdfTreeEditOptions.Default fija esos límites locales del árbol en 1.000.000 de entradas, 100.000 nodos, profundidad 64, 64 entradas por hoja y 32 hijos por nodo. Las cifras de hoja e hijos no son solo guardas; son el fan-out que se usa después al reconstruir 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; { 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
{ el valor son los bytes crudos 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 entrada se quedan como bytes crudos de objeto PDF
Un valor de entrada se guarda literal, y esa es la única decisión de diseño que hace seguro apuntar el editor a archivos que no produjiste tú. TPdfTreeEntry guarda NameKey, NumberKey y RawValue, y RawValue es la secuencia cruda de bytes de un objeto PDF completo: un diccionario, un array, un string, 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érdidas de valores sobre los que la unit no sabe nada. Un /Filespec con una extensión de vendor, un array de destino con un tipo de fit raro, un diccionario de acción /JavaScript con claves privadas, todo sobrevive a una reconstrucción byte a byte, porque el editor nunca tuvo opinión sobre su contenido. También reduce la API de modificación a algo demostrable: UpsertName y UpsertNumber solo tienen que verificar que el valor suministrado parsea como exactamente un objeto completo, y RemoveName, RemoveNumber y Clear solo tocan la lista de entradas
¿Cómo se reconstruye un árbol equilibrado sin renumerar la raíz?
En dos pasadas con un punto fijo. BuildBalancedObjects primero corta la lista ordenada de entradas en hojas de a lo sumo MaxLeafEntries, luego agrupa cada nivel en padres de a lo sumo MaxKidsPerNode y repite hasta que queda un solo nodo. Los limits se calculan de abajo arriba, tomando una hoja su primera y su última clave y un nodo interno 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 recibe siempre 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 antes solo cuando el tipo de nodo coincide y el rango completo de claves coincide, y si no toma un número nuevo asignado desde el máximo número de objeto activo más uno. Esa combinación mantiene los subárboles sin cambios apuntando a sus objetos existentes y garantiza a la vez 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 es la que impulsa el editor de outlines y su reasignación de páginas en la unit hermana
var
Serialized: TPdfTreeSerializedObjects;
I: Integer;
begin
{ FirstNewObjectNumber viene de la revisión fuente }
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 está terminado cuando la salida se ha vuelto a abrir y se ha comparado. SaveIncremental lee los datos de la revisión fuente, se niega a una raíz que sea el propio catálogo del documento, serializa el conjunto de nodos equilibrado más el objeto del catálogo, y se lo entrega todo a AppendPdfSparseUpdate, que añade una revisión nueva según §7.5.6 preservando la referencia /Info existente y el array /ID del documento
Luego viene la parte que merece la pena copiar en tus propios escritores. El editor llama otra vez a TPdfTreeEditor.TryLoad, esta vez contra el stream de destino, con la misma referencia de raíz y las mismas opciones, y compara el recuento de entradas recargadas y cada clave y RawValue con lo que pretendía escribir. Si la recarga falla o cualquier comparación difiere, el stream de destino se resetea en lugar de devolverse, así que una revisión escrita a medias nunca se entrega como éxito. La verificación lee la salida por el mismo camino sparse acotado que describe nuestro artículo sobre el índice sparse lazy de objetos PDF, que es lo que mantiene la comprobación asequible en archivos grandes
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 reseteado, nada escrito a medias se conservó }
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 concreta y no defensiva: una reconstrucción de árbol introduce objetos string nuevos y puede introducir referencias a stream nuevas, y §7.6 exige cifrarlos por objeto con la clave del documento y el número de objeto y la generación propietarios. Copiar el trailer /Encrypt hacia delante mientras se añaden strings en claro produciría un archivo que se abre y luego se lee como basura exactamente donde están tus claves nuevas, que es peor que una negativa clara. Otros dos límites merecen enunciarse sin rodeos. El editor trabaja sobre una instantánea del stream fuente, así que la fuente no debe modificarse mientras haya un editor 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 resulta que contiene árboles, no un nodo de árbol en sí. Si mantienes código Delphi, C++Builder o Lazarus que escribe /EmbeddedFiles, /Dests, /JavaScript o /PageLabels y has estado parcheando arrays de hoja a mano, el editor de árboles y su guardado incremental verificado viajan en el componente PDFiumPas de PDFium para Delphi