El síntoma apareció en una utilidad de copia de páginas construida sobre el HotPDF Component: solicitar la página 1 de un documento de tres páginas producía sistemáticamente la página 2. La revisión de la lógica de indexación no encontró nada incorrecto. La llamada usaba un índice lógico con base 0, la aritmética era correcta y las condiciones de límite eran correctas. Sin embargo, la página incorrecta aparecía cada vez.
El fallo no estaba en absoluto en el código de copia. Estaba en cómo HotPDF construía su array interno de páginas al cargar el fichero.

Dos ordenaciones, una fuente de confusión
Un fichero PDF es una colección de objetos indirectos, cada uno identificado por un número de objeto. La estructura del fichero no impone ninguna obligación sobre esos números para que reflejen el orden de lectura. El objeto 1 puede contener la página 2; el objeto 20 puede contener la página 1. Lo que realmente define el orden de lectura es el árbol de páginas: una jerarquía de diccionarios /Pages cuyos arrays /Kids listan las referencias de página en la secuencia en que un visor debe mostrarlas (ISO 32000-1 §7.7.3).
El documento que desencadenó el fallo tenía esta estructura de árbol de páginas:
{ Pages tree root, object 16 }
16 0 obj
<<
/Type /Pages
/Count 3
/Kids [20 0 R { logical page 1 }
1 0 R { logical page 2 }
4 0 R] { logical page 3 }
>>
endobj
El fichero colocaba el objeto 1 y el objeto 4 antes que el objeto 20 en el flujo de bytes. Cualquier analizador que iterase los objetos indirectos en el orden del fichero y los registrase en un PageArr a medida que encontrara diccionarios de tipo página terminaría con el objeto 1 en el índice 0, el objeto 4 en el índice 1 y el objeto 20 en el índice 2. La página lógica 1 se encuentra en PageArr[2]. Solicitar el índice de página 0 obtiene la página lógica 2 en su lugar.
Eso es exactamente lo que hacían ambas rutas de análisis internas de HotPDF. La ruta tradicional, usada para ficheros PDF 1.3/1.4, y la ruta moderna, usada para documentos con flujos de objetos (PDF 1.5+), construían cada una PageArr recorriendo los objetos indirectos en el orden físico del fichero en lugar de seguir la cadena /Kids.
Confirmación de la hipótesis
Antes de tocar ninguna corrección, había que demostrar la discordancia en lugar de asumirla. La herramienta de línea de comandos qpdf facilita esto:
{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }
qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }
Extraer cada página individualmente y comprobar los tamaños de fichero confirmó el mapeo: lo que producía PageArr[0] era el contenido perteneciente a la página lógica 2, y PageArr[2] contenía la página lógica 1. El desplazamiento circular era la prueba irrefutable. Esto también explicaba por qué el problema aparecía en múltiples documentos fuente diferentes: cualquier PDF donde los objetos de página tuviesen números de objeto inferiores a los de una página lógica anterior lo desencadenaría.
Existe una razón clara por la que los PDF acaban en este estado. Los guardados incrementales añaden objetos actualizados con nuevos números de objeto, dejando las ranuras antiguas en la tabla de referencias cruzadas apuntando a ningún sitio. Los editores que añaden una portada la insertan con un número de objeto alto independientemente de su posición en el array Kids. Algunos generadores simplemente escriben las páginas en un orden conveniente para el streaming de contenido en lugar de en la secuencia lógica de páginas. El formato PDF no les exige hacer lo contrario.
La corrección: seguir el array Kids
El enfoque correcto es construir PageArr recorriendo la cadena /Kids desde la raíz del catálogo, no escaneando los objetos indirectos. Tras completar ambas rutas de análisis su pasada inicial, un paso de postprocesamiento resuelve el orden lógico:
procedure THotPDF.ReorderPageArrByPagesTree;
var
PagesObj : THPDFDictionaryObject;
KidsArray : THPDFArrayObject;
NewPageArr: array of THPDFDictArrItem;
I, J, PageIndex, KidsIndex: Integer;
RefObj : THPDFLink;
PageObjNum: Integer;
Found : Boolean;
begin
{ Locate root /Pages dictionary via FRootIndex }
PagesObj := FindPagesRootFromCatalog;
if PagesObj = nil then Exit;
KidsIndex := PagesObj.FindValue('Kids');
if KidsIndex < 0 then Exit;
KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));
SetLength(NewPageArr, KidsArray.Items.Count);
PageIndex := 0;
for I := 0 to KidsArray.Items.Count - 1 do
begin
RefObj := THPDFLink(KidsArray.GetIndexedItem(I));
PageObjNum := RefObj.Value.ObjectNumber;
Found := False;
for J := 0 to Length(PageArr) - 1 do
begin
if PageArr[J].PageLink.ObjectNumber = PageObjNum then
begin
NewPageArr[PageIndex] := PageArr[J];
Inc(PageIndex);
Found := True;
Break;
end;
end;
{ Non-page Kids (intermediate /Pages nodes) produce no match; skip }
end;
if PageIndex > 0 then
begin
SetLength(PageArr, PageIndex);
for I := 0 to PageIndex - 1 do
PageArr[I] := NewPageArr[I];
end;
end;
La llamada se inserta al final de cada ruta de análisis, después de que todos los objetos hayan sido catalogados pero antes de que se atienda ninguna operación de página:
{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Modern path (object streams) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
El paso de reordenación es O(n * m) donde n es el recuento de Kids y m es la longitud actual de PageArr, pero para cualquier documento con un árbol de páginas plano (todas las hojas a profundidad 1, lo que abarca la gran mayoría de los PDF del mundo real) ambos tienen el mismo valor y el coste es despreciable. Los árboles de páginas profundamente anidados requieren un recorrido recursivo en lugar del enfoque de un solo nivel mostrado aquí; la implementación de producción gestiona ese caso por separado.
Uso de CopyPageFromDocument tras la corrección
Con ReorderPageArrByPagesTree en su lugar, los índices de página lógicos funcionan según lo esperado. El método de nivel superior CopyPageFromDocument toma un índice lógico con base 0 y copia la página correcta en el documento destino:
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
Source.LoadFromFile('source.pdf');
Dest.FileName := 'extracted.pdf';
Dest.BeginDoc;
{ Copy logical page 0 (first page the user sees) }
Dest.CopyPageFromDocument(Source, 0, 0);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
CopyPageFromDocument consulta internamente el orden del árbol de páginas en lugar de depender del índice bruto de PageArr, de modo que se comporta correctamente incluso con documentos donde el orden físico y el lógico divergen. Para operaciones en lote, InsertPagesFromDocument acepta un array de índices lógicos y los copia en una sola pasada.
Qué revela esto sobre el análisis de PDF
La especificación PDF es explícita: el orden lógico de páginas está definido por el array /Kids del árbol de páginas, no por los números de objeto ni por los desplazamientos de bytes (ISO 32000-1 §7.7.3.2). Cualquier analizador que use un ordenamiento diferente como atajo producirá resultados correctos en la mayoría de los documentos que procese, porque la mayoría de los generadores escriben las páginas en el orden natural y asignan números de objeto secuenciales. El fallo permanece oculto hasta que alguien carga un PDF que fue editado de forma incremental, reorganizado por otra herramienta o generado por software que eligió un diseño diferente.
Probar únicamente con PDF generados por uno mismo omite completamente esta clase de problema. La corrección de una regresión en el orden de páginas requiere por tanto un corpus de documentos de fuentes variadas: guardados incrementales, documentos escaneados con portadas insertadas, PDF producidos por herramientas que linealizan u optimizan el grafo de objetos de forma diferente. Un documento que desencadenó el fallo original debe permanecer en el conjunto de regresión de forma permanente.
La página del HotPDF Component cubre la API completa para operaciones de páginas, incluidos CopyPageFromDocument, InsertPagesFromDocument y MovePage.