Artículo técnico

Reconstruir tablas xref de PDF dañadas en Delphi

Cuando la tabla de referencias cruzadas de un PDF queda inservible, la solución es ignorarla por completo y reconstruirla a partir del cuerpo del archivo. PDFlibPas Delphi PDF Library hace esto con un escáner de tokens de una sola pasada que registra cada encabezado de objeto indirecto genuino que encuentra, luego recupera el diccionario del trailer y entrega la tabla reconstruida al cargador normal

Qué se rompe primero cuando un PDF está dañado

La tabla de referencias cruzadas es la parte más frágil de un PDF, porque es la única parte que almacena desplazamientos de bytes absolutos. ISO 32000-1 §7.5.4 define esas entradas como desplazamientos de diez dígitos desde el inicio del archivo, y §7.5.5 coloca la palabra clave startxref cerca del final apuntando a la propia tabla. Cada uno de esos números queda invalidado por cualquier edición que desplace bytes. Una sesión FTP que se ejecutó en modo texto y tradujo CRLF, una descarga truncada, un sector que falló en una unidad compartida, una herramienta por lotes que anexó datos sin escribir correctamente una actualización incremental: todas ellas dejan los datos de objeto perfectamente legibles y el índice apuntando a basura

Por eso "el archivo está dañado y se está reparando" es un cuadro de diálogo tan común. Los bytes casi siempre siguen ahí. Lo que desapareció es el mapa. La reconstrucción, por lo tanto, no es una recuperación forense de datos perdidos, es una reconstrucción de un índice que puede derivarse del cuerpo, y tiene éxito con mucha más frecuencia de lo que esperan los usuarios porque el contenido costoso, los árboles de páginas, las fuentes y las imágenes, permanece intacto

Por qué buscar N 0 obj encuentra coincidencias falsas

Una reconstrucción ingenua busca en los bytes crudos el patrón "entero, entero, obj" y registra cada acierto. Encuentra demasiado. PDF es un formato contenedor, y tres regiones de un archivo son opacas para la gramática de objetos: los comentarios (§7.2), las cadenas (§7.3.4) y los datos de flujo (§7.3.8). Cualquiera de ellas puede contener bytes que se leen exactamente como un encabezado de objeto, y ninguna de ellas es un encabezado de objeto. Un pie de foto en una cadena literal, un comentario de depuración que quedó olvidado, o dos megabytes de salida Flate o DCT producirán sin problema algo que se parece a 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

Cada entrada falsa cuesta doble. Contamina la tabla reconstruida con un número de objeto que no existe, y puede eclipsar a un objeto real con el mismo número que aparece más adelante en el archivo. Por eso PDFlibPas no hace coincidencia de patrones en absoluto. Tokeniza, lo que significa que siempre sabe si los bytes bajo el cursor son código o payload, y el payload se omite sin llegar jamás a interpretarse

Una máquina de estados de una sola pasada sobre bloques de 64 KiB

PDFlibPas escanea el archivo completo exactamente una vez, en bloques de 64 KiB, con una máquina de estados construida sobre las reglas de tokens de ISO 32000-1 §7.2 y la sintaxis de objeto indirecto de §7.3.10. Un token termina en un espacio en blanco o en uno de los caracteres delimitadores, y un encabezado de objeto solo se registra cuando se ha visto una secuencia completa de un número de objeto positivo, un número de generación no negativo, y una palabra clave obj desnuda. El desplazamiento registrado es el inicio del token del número de objeto, que es a donde tiene que apuntar una entrada de referencia cruzada, no la posición de la palabra clave obj

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

El detalle importante es que el estado del token y el estado de cadena sobreviven un límite de bloque. Un encabezado que atraviesa la línea de los 65536 bytes sigue siendo reconocido, porque el token parcial, el par de enteros pendiente y las banderas de estar dentro de una cadena se propagan todos al siguiente bloque. Los buffers son fijos: 64 KiB para el escaneo, 32 bytes para el token más largo que puede llegar a importar, y los únicos arreglos que crecen con el archivo son las listas de número de objeto, número de generación y desplazamiento de 64 bits, que son proporcionales a la cantidad real de objetos y no al tamaño del archivo. En la práctica, el escaneo emite lecturas secuenciales y como máximo dos búsquedas (seeks) explícitas sobre todo el documento, que es lo que lo hace viable en las entradas de varios cientos de megabytes que se abordan en el artículo sobre acceso directo para combinar y dividir PDF de gran tamaño

Por qué no se puede confiar en que un flujo termine en endstream

Porque los datos de flujo son bytes arbitrarios, y los bytes arbitrarios pueden deletrear endstream por accidente. Un flujo que comienza después de la palabra clave stream debe omitirse como datos opacos hasta que realmente termine, pero la primera aparición de la palabra clave de cierre es solo una candidata. PDFlibPas resuelve esto exigiendo corroboración: un token endstream se acepta como el final real del flujo solo cuando el siguiente token que no es espacio en blanco es un endobj independiente, la secuencia que exige §7.3.8 alrededor de un objeto de flujo. Un acierto casual dentro de datos comprimidos casi nunca tiene ese seguimiento, así que el escáner permanece dentro del flujo y continúa. Dos reglas más pequeñas importan igual. La palabra clave stream solo entra en estado de flujo cuando es una palabra clave desnuda, de modo que un objeto de nombre como /stream en un diccionario nunca lo activa. Y un token obj o trailer solo se acepta cuando el token no superó el límite de 32 bytes y no comenzó con una barra diagonal. Sin esas dos protecciones, un diccionario de recursos con los nombres de clave equivocados bastaría para desviar el escaneo, que es exactamente la clase de entrada adversarial que se cubre en las notas sobre análisis seguro de PDF no confiables

Encontrar el final real del diccionario del trailer

Recuperar los objetos es solo la mitad del trabajo, porque el cargador todavía necesita un trailer para encontrar /Root. PDFlibPas recuerda las últimas 64 posiciones de la palabra clave trailer encontradas durante el escaneo y las valida hacia atrás, empezando por la más reciente, de modo que gana el trailer utilizable más nuevo y una palabra clave suelta que no va seguida de un diccionario simplemente falla la validación y pasa al candidato anterior. Cada candidato se lee con un límite de 1 MiB, y el final del diccionario se localiza rastreando la profundidad anidada de << y >> junto con los escapes de cadena literal, las cadenas hexadecimales y los comentarios

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

El rastreo de profundidad no es algo académico. Un trailer truncado que pierde /Encrypt convierte un documento cifrado recuperable en uno imposible de abrir, y perder /Info o un subdiccionario personalizado descarta en silencio metadatos de los que puede depender un sistema posterior. Si el archivo está cifrado, el trailer recuperado es lo que permite que la ruta normal de credenciales se ejecute, y la semántica de reintentos es la misma que se describe en el artículo sobre la carga de documentos cifrados

Qué no puede devolverte la reconstrucción

La reconstrucción es un mejor esfuerzo, y ser honesto sobre sus límites es parte de publicarla. Tres casos fallan por completo. Los objetos empaquetados dentro de flujos de objetos (§7.5.7) no son individualmente visibles para un escaneo de bytes, así que si un contenedor sobrevive pero su flujo de referencias cruzadas (§7.5.8) no lo hace, los objetos que contiene no quedan indexados por la reconstrucción. Un archivo cuyo cuerpo en realidad estaba corrupto, en lugar de simplemente mal indexado, producirá encabezados cuyo contenido ya no se puede analizar. Y un archivo sin ninguna palabra clave trailer recuperable y sin catálogo legible no tiene nada a qué anclar un árbol de documento, sin importar cuántos encabezados de objeto se hayan encontrado

Los números de objeto duplicados son el caso intermedio interesante. Un archivo actualizado de forma incremental contiene legítimamente varias generaciones del mismo número de objeto, y la cadena de referencias cruzadas que sobrevive es el único registro de cuál era la vigente. Una reconstrucción no tiene esa cadena, así que registra cada encabezado que ve en el orden del archivo y resuelve por número de objeto después. Por lo general gana la revisión más reciente, lo cual suele ser correcto, pero un documento que se actualizó y luego se revirtió parcialmente puede volver ligeramente distinto de lo que describía el xref original. Los archivos linealizados llevan la misma advertencia desde el otro lado: el diseño de la primera página y las tablas de sugerencias (hint tables) quedan sin sentido una vez que el índice se regenera, así que un archivo reparado debe tratarse como un documento plano, no linealizado

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

El mecanismo de respaldo es automático: PDFlibPas ejecuta el escaneo crudo siempre que la cadena de referencias cruzadas no se puede leer, y también cuando toda entrada en uso declara el desplazamiento cero, que es la firma característica de una tabla que se escribió pero nunca se llenó. GetDocumentRepaired devuelve 1 cuando esa ruta se ejecutó, y vale la pena registrarlo en lugar de ignorarlo, porque un documento que se cargó mediante reconstrucción debería volver a guardarse en un archivo limpio en lugar de dejarse en un flujo de trabajo como si nada hubiera pasado. Guardarlo escribe una tabla de referencias cruzadas nueva y consistente, que es la corrección más barata posible para cada consumidor posterior

La ruta de reconstrucción, el indicador GetDocumentRepaired y el cargador de streaming que se muestran aquí forman parte de PDFlibPas Delphi PDF Library, junto con las API de análisis, renderizado y firma que se cubren en otras partes de este blog