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
/XRefStm de su tráiler conduce al flujo que guarda el resto del índice de objetosPor 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ó
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
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
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