El síntoma apareció en una utilidad de copia de páginas construida sobre HotPDF Component: solicitar la página 1 de un documento de tres páginas producía consistentemente la página 2. Al revisar la lógica de indexación no se encontró ningún error. La llamada usaba un índice lógico basado en 0, la aritmética era correcta, las condiciones límite estaban bien. Sin embargo, siempre salía la página equivocada
El error no estaba en el código de copia en absoluto. Estaba en cómo HotPDF construía su arreglo de páginas interno al cargar el archivo

Dos ordenamientos, una fuente de confusión
Un archivo PDF es una colección de objetos indirectos, cada uno identificado por un número de objeto. La estructura del archivo no impone ninguna obligación de que esos números 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 arreglos /Kids enumeran las referencias de página en la secuencia en que un visor debería mostrarlas (ISO 32000-1 §7.7.3)
El documento que desencadenó el error 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
Resultó que el archivo enumeraba el objeto 1 y el objeto 4 antes que el objeto 20 en el flujo de bytes. Cualquier analizador (parser) que iterara a través de objetos indirectos en el orden del archivo y los estampara en un PageArr a medida que encontraba 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]. Por lo tanto, solicitar el índice de página 0 obtiene la página lógica 2
Eso es exactamente lo que hacían ambas rutas de análisis internas de HotPDF. La ruta tradicional, usada para archivos PDF 1.3/1.4, y la ruta moderna, usada para documentos de flujos de objetos (PDF 1.5+), construían PageArr recorriendo los objetos indirectos en el orden físico del archivo en lugar de seguir la cadena /Kids
Confirmando la hipótesis
Antes de tocar cualquier solución, era necesario probar la falta de coincidencia en lugar de asumirla. La herramienta de línea de comandos qpdf hace que esto sea sencillo:
{ 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 }
Al extraer cada página individualmente y revisar los tamaños de archivo se 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 cambio circular fue la prueba irrefutable. Esto también explicó por qué el problema aparecía en múltiples documentos de origen diferentes: cualquier PDF en el que los objetos de página tuvieran números de objeto más bajos que una página lógica anterior lo desencadenaría
Hay una razón sencilla por la que los PDF terminan en este estado. Los guardados incrementales adjuntan objetos actualizados con nuevos números de objeto, dejando los espacios antiguos en la tabla de referencias cruzadas apuntando a ninguna parte. Los editores que agregan una página de portada la insertan con un número de objeto alto independientemente de su posición en el arreglo Kids. Algunos generadores simplemente escriben las páginas en un orden conveniente para la transmisión de contenido en lugar de en la secuencia lógica de las páginas. El formato PDF no les exige que lo hagan de otra manera
La solución: seguir el arreglo Kids
El enfoque correcto es construir PageArr recorriendo la cadena /Kids desde la raíz del catálogo, no escaneando objetos indirectos. Después de que ambas rutas de análisis completen su pasada inicial, un paso de posprocesamiento 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 entra al final de cada ruta de análisis, después de que todos los objetos hayan sido catalogados pero antes de que se atienda cualquier 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 reordenamiento 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 una profundidad de 1, que cubre la inmensa mayoría de los PDF del mundo real) ambos son el mismo valor y el costo es insignificante. Los árboles de páginas profundamente anidados requieren un recorrido recursivo en lugar del enfoque de un solo nivel que se muestra aquí; la implementación de producción maneja ese caso por separado
Usar CopyPageFromDocument después de la corrección
Con ReorderPageArrByPagesTree en su lugar, los índices de páginas lógicas funcionan como se espera. La función de mayor nivel CopyPageFromDocument toma un índice lógico basado en 0 y copia la página correcta en el documento de 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 basarse en el índice en bruto de PageArr, por lo que se comporta correctamente incluso con documentos donde el orden físico y lógico difieren. Para operaciones por lotes, InsertPagesFromDocument acepta un arreglo de índices lógicos y los copia en una sola pasada
Lo que esto revela sobre el análisis de PDF
La especificación PDF es explícita: el orden lógico de las páginas está definido por el arreglo /Kids del árbol de páginas, no por números de objeto ni 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 procesa, porque la mayoría de los generadores escriben las páginas en el orden natural y asignan números de objeto secuenciales. El error se oculta hasta que alguien carga un PDF que fue editado de forma incremental, reorganizado por otra herramienta o generado por un software que eligió una disposición diferente
Hacer pruebas únicamente con archivos PDF autogenerados omite por completo esta clase de problema. Por lo tanto, la corrección de una regresión en el orden de las páginas necesita un corpus de documentos de fuentes variadas: guardados incrementales, documentos escaneados con páginas de portada insertadas, archivos PDF producidos por herramientas que linealizan u optimizan el gráfico de objetos de manera diferente. Un documento que desencadenó el error original debería permanecer en la suite de regresión permanentemente
La página de HotPDF Component cubre toda la API para operaciones de página, incluyendo CopyPageFromDocument, InsertPagesFromDocument y MovePage