Artículo técnico

Edición de outlines PDF y remapeo de páginas en Delphi

Quiten siete páginas de un manual de 200 páginas y cada bookmark aterriza en un lugar equivocado. La solución no es reconstruir el outline desde una lista plana de títulos. PDFiumPas expone TPdfOutlineEditor, que carga el árbol real de outline, les deja mover y re-apuntar items, y luego corre ApplyPageMap para desplazar cada destino explícito a través de su plan de páginas

¿Por qué eliminar páginas rompe cada bookmark?

Porque un item de outline 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 no apunta a nada. ISO 32000-1 §12.3.2.2 define un destino explícito como un arreglo cuyo primer elemento es una referencia indirecta a un diccionario de página, seguido de un nombre de fit como /Fit o /XYZ. Eliminen la página y les queda una referencia colgante; reordenen las páginas y la referencia sigue válida pero ahora describe un capítulo distinto. PDFiumPas resuelve ese arreglo de vuelta a un número de página cuando carga, así que TPdfOutlineItem.PageNumber les da un índice de página 1-based que coincide con la API pública de TPdf en lugar de un número de objeto. Ese es todo el punto de la abstracción: su lógica de remapeo trabaja en el mismo sistema de coordenadas que el plan de páginas que ya construyeron cuando dividieron, reordenaron o impusieron el documento. Si están construyendo ese plan, la misma convención 1-based corre a través de dividir documentos PDF en múltiples archivos y de la impuesta n-up y el reordenamiento de páginas

El outline es un árbol doblemente enlazado, no una lista

La razón por la que no pueden simplemente serializar un arreglo plano de títulos es que ISO 32000-1 §12.3.3 cablea cada item de outline en cinco enlaces separados: /Parent, /Prev, /Next, /First y /Last. Mover un solo subárbol por tanto reescribe al padre viejo, al padre nuevo, a ambos hermanos vecinos a cada lado del corte y del punto de inserción, y al puntero de padre del nodo movido mismo. Equivoquen uno de esos y los lectores conformes muestran un árbol truncado, o un ciclo. PDFiumPas mantiene el estado de edición como un arreglo depth-first de records TPdfOutlineItem con un Id entero estable, así que un subárbol es una porción contigua y la cadena de hermanos se deriva, jamás 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 item a su propio subárbol, y nombrar un padre que no existe

Edición de outlines con PDFiumPas en Delphi: mover el Capítulo 3 fuera de la Parte I y bajo la raíz del documento reescribe el puntero /Parent del nodo movido más los enlaces /First y hermanos /Prev y /Next alrededor del corte y del punto de inserción
Una llamada Move reescribe el puntero de padre del subárbol levantado y los enlaces de hermanos a ambos lados del corte y del punto de inserción

¿Por qué /Count lleva signo?

Porque el signo carga el estado de expandido, no el tamaño. Un /Count positivo significa que el item está abierto y el número es cuántos descendientes están visibles actualmente; un /Count negativo significa que está colapsado. PDFiumPas escribe el conteo de descendientes para cada item que tiene hijos y lo niega cuando IsOpen es False, y al carga lee el estado de vuelta como IsOpen := HasCount and (CountValue > 0). Este es el bug artesanal más común en los escritores de outline: emitir un conteo sin signo y dejar todo el árbol abierto en silencio

Cómo codifica PDFiumPas el estado de expansión del outline en Delphi: un /Count positivo significa que el item está abierto y cuenta descendientes visibles, un /Count negativo significa colapsado, y un conteo sin signo fuerza a cada lector a expandir todo el árbol
El signo de /Count es el estado de expandido y la magnitud es el conteo de descendientes visibles, así que un conteo sin signo deja todo el árbol abierto en silencio
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);           // se vuelve el 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 maneja ambas formas que la especificación permite. Pasen DestinationInAction como False y PDFiumPas escribe un arreglo /Dest directo; pasen 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 cualquier /Dest y /A existente del item para que los dos no puedan coexistir y discrepar. El sufijo por defecto es /Fit 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 arreglo de destino que ningún lector pueda parsear

¿Cómo consume ApplyPageMap un plan de páginas?

ApplyPageMap toma exactamente el arreglo que su plan de páginas ya validó: NewPageNumbers, indexado por página vieja menos uno, conteniendo el nuevo número de página 1-based o cero cuando esa página no sobrevivió. Recorre el arreglo de items hacia atrás para que eliminar un subárbol jamás invalide un índice que aún no visita, y reporta lo que hizo a través de 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 fue eliminada

  NewPageNumbers[0] := 1;                // página vieja 1 -> página nueva 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // página vieja 10 -> página nueva 3

  // True: eliminar todo el subárbol colgante. False: conservar el item, 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 flag DeleteDangling decide la política para un destino que mapeó a cero, y ambas ramas son deliberadas. Con True, PDFiumPas elimina el item y su subárbol entero, porque un nodo de outline cuyo destino desapareció usualmente encabeza un capítulo que desapareció con él. Con False, el item sobrevive con su título y jerarquía intactos pero su /Dest y /A removidos, que es lo que quieren cuando una persona va a re-apuntarlo en revisión. La entrada genuinamente malformada aún falla ruidosamente en lugar de parcharse: una entrada negativa o un destino que apunte más allá del final del mapa suministrado devuelve False con IssueKind en poviInvalidPageMap

Cómo el ApplyPageMap de PDFiumPas redirige bookmarks PDF en Delphi: un mapa de páginas indexado por página vieja menos uno envía los destinos sobrevivientes a sus nuevos números de página, mientras que las entradas que mapean a cero se eliminan con su subárbol o quedan despojadas de su destino
El mapa de páginas se indexa por página vieja menos uno, y una entrada cero o elimina el subárbol colgante o deja el item con su destino despojado

Entradas opacas, y el trade-off honesto

No todo item de outline tiene un número de página del que PDFiumPas pueda razonar. Tres clases pasan intactas: los destinos nombrados, las acciones que no son /S /GoTo, y las claves de diccionario desconocidas añadidas por lo que sea que produjo el archivo. Estas cargan con PageNumber igual a cero, conservan sus bytes originales en el item, y se escriben de vuelta textualmente salvo que llamen Retarget explícitamente sobre ellas

  • Un destino nombrado es una clave en el name tree del documento, así que remapearlo correctamente significa resolver el árbol y reescribir la entrada de destino, no adivinar al nivel del outline
  • Una acción /URI, /Launch o JavaScript no tiene semántica de página en absoluto y no debe convertirse en silencio a un Go-To
  • Las claves específicas de proveedor y los destinos de estructura se conservan porque tirar lo que no entienden es como los round-trips pierden datos

El costo es real y vale enunciarlo con claridad: ApplyPageMap se salta esos items por completo, así que un documento cuyos bookmarks usan todos destinos nombrados saldrá de una eliminación de páginas con su outline estructuralmente válido y semánticamente obsoleto. Esa es la elección deliberada — un enlace obsoleto que un revisor puede cazar le gana a uno incorrectamente confiado que nadie nota. Si están triageando archivos entrantes antes de editarlos, una pasada de inventario en un workbench de revisión de intake PDF les dirá qué documentos caen en ese balde

Guardar: revisión incremental, luego una recarga independiente

TPdfOutlineEditor.SaveIncremental añade una revisión incremental dispersa en lugar de reescribir el archivo. Los items que fueron cargados conservan su referencia de objeto indirecto original incluyendo la generación exacta, así que las referencias cruzadas existentes siguen válidas; solo los items que añadieron 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 una entrada /Outlines faltante se le añade cuando la fuente no tenía outline alguno

Lo que pasa después de escribir es la parte que vale copiar. PDFiumPas reabre el stream de destino con un editor completamente independiente y compara el árbol recargado contra el en memoria — conteo de items, títulos, números de página, sufijos de destino, forma de acción versus destino directo, estilos, estado de expandido, y relaciones de padre. Cualquier desajuste, o cualquier fallo de carga, limpia el stream de destino y devuelve poviVerificationFailure en lugar de entregarles un archivo de apariencia plausible. Las fuentes cifradas se rechazan de entrada con poviEncryptedInput, porque títulos y destinos nuevos crean contenido de cadena que no puede producirse copiando el trailer /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;

Traten el outline 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 bookmarks y se convierte en un mapa de páginas que le entregan a una llamada de método. TPdfOutlineEditor, ApplyPageMap y el escritor incremental verificado se entregan en PDFiumPas desde v3.98.0 para Delphi, C++Builder y Lazarus; pueden revisar la API completa y descargar una prueba en la página del producto PDFium Delphi Component