Artículo técnico

Cargar PDF de referencia híbrida de Word y Excel en Delphi

Abra un PDF generado por Microsoft Word o Excel y hojee sus páginas: no hay nada fuera de lo normal. Cárguelo en un programa Delphi y lea el recuento de páginas: el número es correcto. Luego vuelva a guardarlo con el cifrado activado y el paso de guardado lanza una excepción, o el resultado se abre con un aviso de referencia cruzada dañada. El archivo nunca estuvo corrupto. Es un archivo de referencia híbrida, y la misma estructura que permite que un visor de hace quince años lo abra es la que hace fracasar a un cargador que deja de leer demasiado pronto

Esta es una de las formas más habituales en que un flujo de procesamiento de PDF que ha superado todas las pruebas internas se topa con un archivo que no puede procesar de ida y vuelta. Todas las entradas se generaron internamente, así que nunca fueron híbridas. El primer archivo híbrido llega el día en que un cliente reenvía una factura exportada desde una hoja de cálculo

Lo que realmente escriben Word y Excel

ISO 32000-1 describe el diseño de referencia híbrida en el §7.5.8.4. Una aplicación que quiere funciones de PDF 1.5, como los flujos de objetos, sin dejar de permitir que un lector de PDF 1.4 abra el archivo, escribe la información de referencias cruzadas dos veces. Existe una tabla de referencias cruzadas clásica, las filas ASCII de ancho fijo que cerraban todo PDF hasta la versión 1.4, y existe un flujo de referencias cruzadas que indexa el resto. El tráiler de la sección clásica lleva una entrada /XRefStm cuyo valor es el desplazamiento en bytes de ese flujo

La división del trabajo es deliberada. Los objetos que un lector antiguo tiene que alcanzar, entre ellos el catálogo y el árbol de páginas, son direccionables desde la tabla clásica. Los objetos que se plegaron en flujos de objetos comprimidos se marcan como libres en la tabla clásica, con una entrada de tipo f, de modo que un lector 1.4 pasa de largo junto a ellos y nunca tropieza con una estructura que no puede analizar. Sus ubicaciones reales solo viven en el flujo de referencias cruzadas. La firma de un archivo así es su cola: una sección clásica breve que cubre solo los objetos que necesita un lector heredado, cuyo tráiler apunta al /XRefStm donde reside en realidad la información de recuperación

Diagrama de HotPDF de la cola de un PDF con referencia híbrida de Word o Excel donde el trailer xref clásico lleva /XRefStm 87325, apuntando de vuelta al stream de referencias cruzadas que indexa campos de formulario y estructura etiquetada invisible para un cargador que se detiene en la tabla clásica
La cola híbrida conserva una tabla clásica con los objetos que necesita un lector heredado, mientras el offset /XRefStm de su tráiler conduce al flujo que guarda el resto del índice de objetos

Por qué un recuento de páginas correcto no demuestra nada

Como el catálogo y el árbol de páginas son alcanzables desde la tabla clásica a propósito, un cargador que solo lee esa tabla encuentra /Root, recorre el árbol de páginas y devuelve el número correcto de páginas. Todo lo que necesita un lector antiguo está presente, así que el archivo parece sano. Los objetos que faltan son los empaquetados en flujos de objetos: diccionarios de campos de AcroForm, elementos de estructura de PDF etiquetado, la larga cola de pequeños diccionarios que nunca tuvieron que ser visibles para un visor heredado

No se advierte el hueco hasta que algo toca esos objetos, y volver a guardar el documento por completo los toca todos. Recorrer el documento para volver a cifrarlo o reescribirlo es precisamente la operación que solicita cada número de objeto por turno, razón por la cual el síntoma aparece al guardar y no al cargar, lejos de su causa

La trampa es un detector que ve xref y se detiene

La forma barata de decidir cómo está indexado un archivo es seguir startxref e inspeccionar los primeros bytes a los que apunta. La palabra clave xref significa una tabla clásica; un objeto de flujo significa un flujo de referencias cruzadas. Esa prueba es correcta para cualquier archivo que se decante por un único esquema. Es incorrecta para un archivo híbrido, cuyo startxref apunta a una sección clásica con el único fin de satisfacer a los lectores antiguos, mientras que el /XRefStm del tráiler de esa sección es donde en realidad se indexa la mayor parte del documento. Un detector que devuelve "classic" ante el primer xref que encuentra nunca lee /XRefStm, y todo objeto que solo vive en el flujo se vuelve invisible

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // las páginas son alcanzables desde la tabla clásica
    // inspeccione o edite aquí el documento cargado
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // necesita todos los objetos, también los que solo viven en el flujo
  finally
    Pdf.Free;
  end;
end;

Ejecute esa secuencia sobre un cargador construido en torno al detector de salida temprana y la carga parece correcta; es en el nuevo guardado donde los objetos ausentes se manifiestan. La solución no es leer más bytes al principio; es reconocer el tráiler híbrido y seguir /XRefStm antes de dar el archivo por terminado

El orden de fusión no es negociable

Una vez leídos ambos índices, solo se pueden combinar en una dirección. El flujo de referencias cruzadas debe fusionarse primero, y las entradas clásicas se rellenan a su alrededor. La razón es el pequeño engaño que hay en el corazón del formato. Un archivo híbrido marca sus objetos comprimidos como libres en la tabla clásica para que los lectores antiguos los ignoren. Un cargador que respeta una política de "el primero que se ve, gana" y lee primero la tabla clásica registrará esos números de objeto como libres, y después descartará las entradas del flujo que en realidad los localizan, porque las ranuras ya están ocupadas. Invierta el orden y las entradas de tipo 2 del flujo, cada una con un número de flujo de objetos más un índice, se quedan con las ranuras que les corresponden, y las entradas clásicas se acomodan a su alrededor

La misma disciplina protege contra que una revisión más antigua resucite un objeto eliminado. Las actualizaciones incrementales se encadenan hacia atrás mediante /Prev, y una entrada libre de tipo 0 es un centinela que indica que una sección más reciente ha retirado un número de objeto. No se debe permitir que una sección posterior, pero más antigua en la cadena, sobrescriba ese centinela con una ubicación obsoleta. Trate el primer valor visto como autoritativo para los marcadores libres y el objeto eliminado permanece eliminado; trátelo con descuido y la propia historia del archivo reanima contenido que la revisión más reciente eliminó

Diagrama de HotPDF que contrasta dos órdenes de fusión para datos xref híbridos: leer primero la tabla clásica marca el objeto 12 como libre y descarta su entrada tardía de tipo 2, de modo que el nuevo guardado no puede localizarlo, mientras fusionar primero el flujo de referencias cruzadas deja que cada entrada reclame su ranura y la tabla clásica se asiente a su alrededor
Leer primero el flujo permite que las entradas de tipo 2 reclamen sus ranuras, de modo que la tabla clásica se asienta en torno a ellas en lugar de borrarlas

Cómo se desincronizan las dos vistas

Todo lo anterior asume que los dos índices están de acuerdo. Recién salido de Word, lo están: cada vista describe el mismo documento dentro del alcance que declara. Los problemas empiezan cuando el archivo pasa por una herramienta que solo entiende una de ellas. Las utilidades de estampado son las sospechosas habituales. Una añade una actualización incremental de estilo clásico con unos pocos objetos nuevos, una sección xref nueva, un enlace /Prev a la sección anterior y un tráiler fresco. Si ese tráiler pierde el /XRefStm, la vista del flujo queda huérfana. Si copia el valor antiguo hacia adelante, la vista del flujo sigue describiendo el documento tal como estaba antes de que se aplicara el sello. En cualquier caso, los índices ya no coinciden

PDF: cola de un PDF de referencia híbrida donde un lector heredado se fía de la tabla xref clásica mientras un lector moderno sigue el /XRefStm hasta la vista del flujo comprimido
Una clave del tráiler decide la vista: la tabla clásica sirve a los lectores antiguos, mientras el /XRefStm entrega a los lectores modernos el flujo que localiza todo lo demás

La firma es fácil de reconocer una vez que se conoce. Los objetos visibles a través de una vista faltan o están obsoletos en la otra. Un lector que resuelve por el flujo encuentra la versión previa a la edición de un objeto actualizado, o ninguna entrada para uno añadido. Un lector que se fía solo de la tabla ve la edición pero pierde cada objeto comprimido que solo el flujo podía localizar. Los usuarios lo describen como campos de formulario que aparecen en un visor y desaparecen en otro, anotaciones que el paso de estampado parece haber borrado, o búsquedas que caen por completo en el objeto equivocado

Adobe Acrobat es lo que hace que rastrear estos archivos sea tan costoso. Cuando el índice declarado discrepa de los bytes, Acrobat normalmente reconstruye en silencio los datos de referencias cruzadas escaneando cabeceras de objetos, así que quien produjo el archivo dañado lo abre, no ve nada anómalo y lo reenvía. El fallo aflora más tarde y en otro sitio — un validador de preflight, un servicio de firma, un trabajo de ingestión para archivo — cualquier consumidor que se fíe de la estructura declarada y reporte objetos ausentes o una discrepancia de referencias cruzadas. "Se abre bien en Acrobat" es como empiezan casi todos los tickets de desincronización híbrida

Qué significa esto en HotPDF

El motor resuelve por usted los archivos de referencia híbrida, y lo hace en cada ruta que tiene que analizar los datos de referencias cruzadas. Cargue un documento con LoadFromFile o LoadFromStream, realice sus cambios y llame a SaveLoadedDocument; o ejecute una operación de un solo paso, como EncryptFile, que lee una entrada y escribe una salida. En ambos casos la recuperación lee /XRefStm, fusiona la sección del flujo antes que las entradas clásicas y resuelve los objetos que viven en flujos antes de que la escritura los enumere. La ruta de cifrado AES-256 es donde esto se aprecia con más claridad, porque cifrar un documento reescribe todos los objetos y, por tanto, exige que todos los objetos ya estén localizados

// Un solo paso: lee la entrada híbrida y escribe una copia cifrada con AES-256
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

El detalle que vale la pena retener está aguas arriba de la API. Los archivos que llegan de Word, Excel, PowerPoint y una larga lista de flujos de "Guardar como PDF" son habitualmente híbridos, así que un cargador que solo se ejercita contra la salida de su propio generador puede que nunca se encuentre con uno en las pruebas. Nutra sus conjuntos de prueba con documentos exportados desde aplicaciones de Office reales, no solo con archivos que produjo su propio código

Cómo comprobar un archivo sospechoso

Dos comprobaciones resuelven la duda con rapidez. Abra el archivo en una vista hexadecimal y lea los bytes posteriores al último startxref; un archivo híbrido muestra una sección clásica breve cuyo diccionario de tráiler contiene /XRefStm. O compare el recuento de objetos que informa un análisis completo con el número de objeto más alto que declara /Size en el tráiler. Una diferencia grande significa que hay objetos escondidos en flujos que el cargador no ha abierto, la misma carencia que más tarde se convierte en un fallo al guardar

La cola de una exportación típica de Excel hace tangible la primera comprobación. Todo lo que sigue a la última palabra clave xref es ASCII plano, así que la firma se puede leer directamente en una vista hexadecimal (desplazamientos ilustrativos, anotaciones añadidas)

xref
0 16                         % objetos 0-15: lo que necesita un lector heredado
0000000000 65535 f
0000000017 00000 n
...                          % 14 filas más; el resto vive en flujos de objetos
trailer
<< /Size 216                 % uno más que el número de objeto más alto en uso
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % desplazamiento en bytes del flujo de referencias cruzadas
>>
startxref
88710                        % apunta a la sección clásica de arriba
%%EOF

La clave /XRefStm del tráiler es la señal reveladora. La sección clásica de arriba lista 16 de los 216 números de objeto que usa el archivo; todo lo empaquetado en flujos de objetos está ausente de ella o marcado como libre. Un detector que se detiene en la palabra clave xref ha visto, llegado a este punto, solo ese índice parcial. Cuando prefiera automatizar la comprobación en lugar de hacerla a simple vista, el marcador se encuentra dentro de los últimos kilobytes del archivo, así que basta con una lectura acotada hacia atrás. Si el archivo se actualizó después de forma incremental, la cola guarda más de un tráiler, y el que cuenta es el último

// Devuelve el desplazamiento de /XRefStm desde el tráiler más nuevo de la cola del
// archivo, o -1 si el marcador está ausente (el archivo no es híbrido, o no es un PDF)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P, Q: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // el tráiler vive en la cola
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // lectura acotada hacia atrás: 2 KB máx.
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := 0;                               // conserva el último marcador: las actualizaciones
  Q := Pos(AnsiString('/XRefStm'), Tail); // incrementales añaden tráilers más nuevos
  while Q > 0 do
  begin
    P := Q;
    Q := Pos(AnsiString('/XRefStm'), Tail, P + 1);
  end;
  if P = 0 then
    Exit;                               // no hay marcador híbrido en la cola
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // omite los espacios en blanco tras la clave
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Uso: un resultado no negativo indica el byte donde comienza el flujo
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Trate la sonda como una clasificación previa, no como un analizador: le indica qué archivos de un lote merecen atención antes de que se ejecute un trabajo de reguardado, y nada más

Apúntela a una carpeta de entradas reales y los archivos se reparten en tres grupos. Un archivo solo clásico tiene una palabra clave trailer en su cola pero ningún /XRefStm, así que la sonda devuelve -1. Un archivo que se decanta por completo por los flujos de referencias cruzadas también devuelve -1, correctamente: no tiene palabra clave trailer alguna, porque sus claves de tráiler viven en el diccionario del flujo. Solo la disposición de doble índice arroja un desplazamiento. Si su informe de clasificación necesita distinguir los dos primeros grupos, busque trailer en la misma cola antes de descartarla; una llamada Pos más lo resuelve

Antes de que la sonda corra desatendida, refuércela en dos sitios. Un desplazamiento es solo una afirmación. Salte a él y confirme que un objeto cuyo diccionario dice /Type /XRef está realmente allí, porque un archivo dañado o truncado puede seguir llevando la clave mientras el flujo que nombra ya no está. Ese archivo va en un grupo distinto del de un híbrido sano. La comprobación son unas pocas líneas sobre el mismo manejo de archivos

// Offset es el valor devuelto por FindXRefStm; FS es un TFileStream abierto
// igual, y Head un AnsiString (Min viene de System.Math)
if Offset >= FS.Size then
  Exit(False);                          // la clave sobrevivió, el flujo no
FS.Position := Offset;
SetLength(Head, Integer(Min(Int64(512), FS.Size - Offset)));
FS.ReadBuffer(Head[1], Length(Head));
Result := (Pos(AnsiString('/Type /XRef'), Head) > 0) or
  (Pos(AnsiString('/Type/XRef'), Head) > 0);  // la sintaxis PDF admite ambas escrituras

Luego convierta la ventana de 2 KB en un parámetro. Dos kilobytes cubren la salida ordinaria de Office, pero un diccionario de tráiler inusualmente grande puede empujar el marcador fuera de rango, y ensanchar la ventana es preferible a declarar el archivo clásico por accidente

Enrutar los archivos híbridos por un pipeline

La detección rinde cuando cambia el destino siguiente de un archivo. Cuál sea ese destino depende de lo que el pipeline esté a punto de hacer con él

Flujo de decisión en Delphi que escanea la cola del archivo buscando el último tráiler y el /XRefStm, y después enruta los PDF híbridos hacia la validación, la normalización o el manejo de solo anexado
Una búsqueda acotada en la cola arroja tres veredictos, y solo el caso de doble índice sigue su curso como híbrido auténtico

Los archivos que solo se leen, renderizan o validan deben ir a un cargador que resuelve ambas vistas. El PDFium Component analiza la cadena /XRefStm durante la carga, así que la tabla de objetos que ve su código ya es la fusionada, y las comprobaciones de nuestro artículo sobre la validación de flujos de objetos y de referencias cruzadas se aplican sin cambios. Cuando un híbrido desincronizado está lo bastante dañado como para negarse a cargarse, el motor lo reporta por su código de error de carga, y FPDF_ERR_FORMAT es el que produce el daño estructural. Una carga limpia es evidencia más débil. PDFium es tolerante por diseño y reconstruye sin comentario la mayoría de los archivos inconsistentes, así que el éxito prueba que el archivo era recuperable y no dice nada sobre si sus dos vistas coinciden. Para esa pregunta, vuelva a comparar lo que encuentra un recorrido completo de objetos con el /Size del tráiler

Los archivos que el pipeline modifica son otra historia. Aquí nuestra preferencia es tajante: que dejen de ser híbridos del todo. Cargue el documento con HotPDF y guárdelo por completo, y la salida lleva una única referencia cruzada autoconsistente en una sola forma. Sin /XRefStm, sin una segunda vista que quede a la deriva, cada objeto propiedad de exactamente una entrada del índice. Esa normalización es la que quiere antes de una ingestión para archivo, antes de un RIP o un servicio de firma estricto aguas abajo, y después de cualquier edición aplicada a una entrada híbrida. Se apoya por completo en la fusión con el flujo primero descrita antes, ya que un guardado completo solo puede escribir los objetos que la carga logró localizar

Los documentos firmados son la excepción. Una reescritura completa mueve cada byte, lo que invalida cualquier firma calculada sobre los rangos de bytes originales. Un cambio en un híbrido firmado tiene que entrar como una actualización incremental propiamente dicha que mantenga ambas vistas en paso, y un archivo firmado que solo necesita leerse debería atravesar el pipeline intacto

La parte de la historia relativa a quien escribe, cómo se producen en primer lugar los flujos de objetos y las referencias cruzadas comprimidas, se trata en nuestro artículo sobre flujos de objetos y actualizaciones incrementales. Cuando el archivo híbrido en cuestión también es muy grande, las técnicas de carga descritas en el recorrido por la API de archivo directo para flujos de trabajo con PDF de gran tamaño le permiten inspeccionarlo sin cargarlo por completo en memoria. Ambas encajan de forma natural con la recuperación descrita aquí, que se incluye como parte del HotPDF Delphi Component para Delphi y C++Builder junto con las API de carga, edición, cifrado y firma tratadas en otras partes de este blog