Suelta siete páginas de un manual de 200 páginas y cada marcador aterriza en algún sitio equivocado. El arreglo no es reconstruir el esquema desde una lista plana de títulos. PDFiumPas expone TPdfOutlineEditor, que carga el árbol de esquema real, te deja mover y redirigir elementos, y después ejecuta ApplyPageMap para desplazar cada destino explícito a través de tu plan de páginas
¿Por qué eliminar páginas rompe todos los marcadores?
Porque un elemento de esquema no almacena un número de página. Almacena una referencia a un objeto de página, y cuando los objetos de página cambian la referencia o apunta a una página que se movió o a nada en absoluto. ISO 32000-1 §12.3.2.2 define un destino explícito como un array cuyo primer elemento es una referencia indirecta a un diccionario de página, seguido de un nombre de ajuste como /Fit o /XYZ. Elimina la página y te queda una referencia colgante; reordena las páginas y la referencia sigue válida pero ahora describe un capítulo distinto. PDFiumPas resuelve ese array de vuelta a un número de página al cargar, así que TPdfOutlineItem.PageNumber te da un índice de página uno-basado que coincide con la API pública de TPdf en lugar de un número de objeto. Ese es el punto entero de la abstracción: tu lógica de remapeo trabaja en el mismo sistema de coordenadas que el plan de páginas que ya construiste al dividir, reordenar o imponer el documento. Si estás construyendo ese plan, la misma convención uno-basada atraviesa dividir documentos PDF en varios archivos y imposición n-up y reordenación de páginas
El esquema es un árbol doblemente enlazado, no una lista
La razón por la que no puedes simplemente serializar un array plano de títulos es que ISO 32000-1 §12.3.3 cablea cada elemento de esquema en cinco enlaces separados: /Parent, /Prev, /Next, /First y /Last. Mover un único subárbol reescribe por tanto el padre antiguo, el padre nuevo, ambos hermanos vecinos a cada lado del corte y del punto de inserción, y el puntero de padre del propio nodo movido. Equivócate en uno de ellos y los lectores conformes muestran un árbol truncado, o un bucle. PDFiumPas mantiene el estado de edición como un array en profundidad de registros TPdfOutlineItem con un Id entero estable, de modo que un subárbol es una porción contigua y la cadena de hermanos se deriva, nunca se mantiene a mano. TPdfOutlineEditor.Move levanta esa porción, la reinserta bajo el nuevo padre en el índice de hermano solicitado, y reasigna solo la raíz del bloque. También rechaza los dos movimientos que corromperían el grafo: mover un elemento a su propio subárbol, y nombrar un padre que no existe
¿Por qué /Count lleva signo?
Porque el signo porta el estado expandido, no el tamaño. Un /Count positivo significa que el elemento está abierto y el número es cuántos descendientes hay visibles actualmente; un /Count negativo significa que el elemento está plegado. PDFiumPas escribe el recuento de descendientes de todo elemento que tiene hijos y lo niega cuando IsOpen es False, y al cargar lee el estado de vuelta como IsOpen := HasCount and (CountValue > 0). Este es el bug artesanal más común de cuantos escriben esquemas: emitir un recuento sin signo y forzar silenciosamente todo el árbol abierto
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000, MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // pasa a ser segundo hijo de la raíz
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // escribe un /Count negativo
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget gestiona ambas formas que la especificación permite. Pasa DestinationInAction como False y PDFiumPas escribe un array /Dest directo; pasa True y escribe una acción Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, según ISO 32000-1 §12.6.4.2. En cualquier caso primero despoja del elemento cualquier /Dest y /A existentes para que ambos no puedan coexistir y discrepar. El sufijo es /Fit por defecto y debe empezar con un nombre PDF, razón por la que un sufijo vacío o malformado lanza de inmediato en lugar de producir un array de destino que ningún lector pueda analizar
¿Cómo consume ApplyPageMap un plan de páginas?
ApplyPageMap toma exactamente el array que tu plan de páginas ya validó: NewPageNumbers, indexado por página antigua menos uno, conteniendo el nuevo número de página uno-basado o cero cuando esa página no sobrevivió. Recorre el array de elementos hacia atrás para que eliminar un subárbol nunca invalide un índice que aún no ha visitado, y reporta lo que hizo mediante RemappedDestinationCount y RemovedDanglingItemCount
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// Una entrada por página del documento ORIGINAL
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == esta página se descartó
NewPageNumbers[0] := 1; // página antigua 1 -> página nueva 1
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // página antigua 10 -> página nueva 3
// True: eliminar todo el subárbol colgante. False: conservar el elemento, despojar su destino
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
El indicador DeleteDangling decide la política para un destino que mapeó a cero, y ambas ramas son deliberadas. Con True, PDFiumPas elimina el elemento y todo su subárbol, porque un nodo de esquema cuyo objetivo desapareció suele encabezar un capítulo que desapareció con él. Con False, el elemento sobrevive con su título y jerarquía intactos pero con su /Dest y /A eliminados, que es lo que quieres cuando una persona va a redirigirlo en revisión. La entrada genuinamente malformada sigue fallando a voces en lugar de parchearse: una entrada negativa o un destino que apunta más allá del final del mapa suministrado devuelve False con IssueKind puesto a poviInvalidPageMap
Entradas opacas, y el compromiso honesto
No todo elemento de esquema tiene un número de página sobre el que PDFiumPas pueda razonar. Tres clases se transportan intactas: destinos con nombre, acciones que no son /S /GoTo, y claves de diccionario desconocidas añadidas por quien produjera el archivo. Estas cargan con PageNumber igual a cero, conservan sus bytes originales en el elemento, y se escriben de vuelta literalmente salvo que llames explícitamente a Retarget sobre ellas
- Un destino con nombre es una clave en el árbol de nombres del documento, así que remapearlo correctamente significa resolver el árbol y reescribir la entrada de destino, no adivinar a nivel de esquema
- Una acción
/URI,/Launcho JavaScript no tiene semántica de página alguna y no debe convertirse silenciosamente en un Go-To - Las claves específicas de fabricante y los destinos de estructura se conservan porque soltar lo que no entiendes es como los viajes de ida y vuelta pierden datos
El coste es real y merece decirse llanamente: ApplyPageMap se salta esos elementos por completo, así que un documento cuyos marcadores usan todos destinos con nombre saldrá de una eliminación de páginas con su esquema estructuralmente válido y semánticamente obsoleto. Esa es la elección deliberada — un enlace obsoleto que un revisor puede cazar vale más que uno erróneo con confianza que nadie nota. Si estás triajando archivos entrantes antes de editarlos, una pasada de inventario en un banco de revisión de entrada PDF te dirá qué documentos caen en ese cubo
Guardar: revisión incremental, después una recarga independiente
TPdfOutlineEditor.SaveIncremental añade una revisión incremental dispersa en lugar de reescribir el archivo. Los elementos que se cargaron conservan su referencia de objeto indirecto original incluyendo la generación exacta, así que las referencias cruzadas existentes siguen válidas; solo los elementos que añadiste sacan un número fresco, asignado desde uno más allá del número máximo de objeto de la revisión. El catálogo se actualiza en la misma revisión, y se le añade una entrada /Outlines ausente cuando el origen no tenía esquema alguno
Lo que ocurre tras la escritura es la parte que merece copiarse. PDFiumPas reabre el flujo de destino con un editor completamente independiente y compara el árbol recargado contra el en memoria — recuento de elementos, títulos, números de página, sufijos de destino, forma acción-frente-a-destino-directo, estilos, estado expandido y relaciones de padre. Cualquier discrepancia, o cualquier fallo de carga, limpia el flujo de destino y devuelve poviVerificationFailure en lugar de entregarte un archivo de apariencia plausible. Los orígenes cifrados se rechazan de entrada con poviEncryptedInput, ya que los títulos y destinos nuevos crean contenido de cadena que no puede producirse copiando el tráiler /Encrypt hacia adelante
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
Trata el esquema como lo que es — un grafo de objetos enlazado con sus propios invariantes — y la eliminación de páginas deja de ser un desastre de marcadores y se convierte en un mapa de páginas que entregas a una llamada de método. TPdfOutlineEditor, ApplyPageMap y el escritor incremental verificado se incluyen en PDFiumPas desde v3.98.0 para Delphi, C++Builder y Lazarus; puedes revisar la API completa y descargar una prueba en la página de producto del PDFium Delphi Component