Artículo técnico

Errores en el orden de las páginas PDF en HotPDF: estructura física vs lógica

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

Concepto de orden de página PDF: diferencia entre orden físico y orden lógico
Orden de páginas PDF: el arreglo /Kids en el árbol Pages define la secuencia lógica, independiente de cómo se numeran o almacenan los objetos en 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