Artículo técnico

Reconstruir tablas xref de PDF dañadas: recuperación Delphi

Cuando una tabla de referencias cruzadas de PDF es inutilizable, la solución es ignorarla por completo y reconstruirla a partir del cuerpo del fichero. La PDFlibPas Delphi PDF Library hace esto con un escáner de tokens de una sola pasada que registra cada cabecera de objeto indirecto genuina que ve, después recupera el diccionario de 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 offsets de byte absolutos. ISO 32000-1 §7.5.4 define esas entradas como offsets de diez dígitos desde el inicio del fichero, 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 se estropeó en una unidad compartida, una herramienta por lotes que añadió contenido 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 fichero está dañado y se está reparando" es un diálogo tan habitual. Los bytes casi siempre siguen ahí. Lo que ha desaparecido es el mapa. La reconstrucción por 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 la que los usuarios esperan porque el contenido costoso, los árboles de página y las fuentes e imágenes, permanece intacto

¿Por qué buscar N 0 obj encuentra coincidencias falsas?

Una reconstrucción ingenua busca en los bytes en crudo el patrón "entero, entero, obj" y registra cada acierto. Encuentra demasiado. PDF es un formato contenedor, y tres regiones de un fichero son opacas para la gramática de objetos: comentarios (§7.2), cadenas (§7.3.4), y datos de stream (§7.3.8). Cualquiera de ellas puede contener bytes que se lean exactamente como una cabecera de objeto, y ninguna de ellas es una cabecera de objeto. Un pie de foto en una cadena literal, un comentario de depuración sobrante, o dos megabytes de salida Flate o DCT producirán todos alegremente algo que parece 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 el 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 fichero. PDFlibPas por tanto no hace coincidencia de patrones en absoluto. Tokeniza, lo que significa que siempre sabe si los bytes bajo el cursor son código o contenido, y el contenido se salta sin interpretarse jamás

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

PDFlibPas escanea el fichero entero exactamente una vez, en bloques de 64 KiB, con una máquina de estados construida sobre las reglas de token 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 una cabecera 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 offset registrado es el inicio del token de número de objeto, que es a lo que 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 de token y el estado de cadena sobreviven a un límite de bloque. Una cabecera que atraviesa la línea de 65536 bytes se sigue reconociendo, porque el token parcial, el par de enteros pendiente, y los flags de dentro-de-cadena se llevan todos al siguiente bloque. Los búferes son fijos: 64 KiB para el escaneo, 32 bytes para el token más largo que posiblemente pueda importar, y los únicos arrays que crecen con el fichero son las listas de número de objeto, número de generación y offset de 64 bits, que son proporcionales al recuento real de objetos en lugar de al tamaño del fichero. En la práctica el escaneo emite lecturas secuenciales y como mucho dos seeks explícitos sobre todo el documento, que es lo que hace que sea viable en las entradas de varios cientos de megabytes tratadas en el artículo sobre fusión y división de acceso directo

¿Por qué no se puede confiar en que un stream termine en endstream?

Porque los datos de stream son bytes arbitrarios, y los bytes arbitrarios pueden deletrear endstream por accidente. Un stream que empieza tras la palabra clave stream debe saltarse como datos opacos hasta que genuinamente termina, pero la primera aparición de la palabra clave de cierre es solo un candidato. PDFlibPas resuelve esto exigiendo corroboración: un token endstream se acepta como el final real del stream solo cuando el siguiente token que no es espacio en blanco es un endobj independiente, la secuencia que exige §7.3.8 en torno a un objeto de stream. Un acierto casual dentro de datos comprimidos casi nunca tiene ese seguimiento, así que el escáner se queda dentro del stream y sigue adelante. Dos reglas menores importan tanto como esa. La palabra clave stream solo entra en el estado de stream cuando es una palabra clave desnuda, así que un objeto nombre como /stream en un diccionario nunca lo dispara. Y un token obj o trailer solo se respeta cuando el token no desbordó el límite de 32 bytes y no empezaba con una barra. Sin esas dos salvaguardas un diccionario de recursos con los nombres de clave equivocados bastaría para descarrilar el escaneo, que es precisamente la clase de entrada hostil cubierta en las notas sobre analizar PDF no confiable de forma segura

Encontrar el final real del diccionario de 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 palabra clave trailer encontradas durante el escaneo y las valida hacia atrás, la más reciente primero, así que el trailer utilizable más nuevo gana y una palabra clave suelta que no va seguida de un diccionario simplemente falla la validación y cae 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 académico. Un trailer truncado que pierde /Encrypt convierte un documento cifrado recuperable en uno inabrible, y perder /Info o un subdiccionario personalizado descarta silenciosamente metadatos de los que un sistema río abajo puede depender. Si el fichero está cifrado, el trailer recuperado es lo que permite que se ejecute la ruta normal de credenciales, y la semántica de reintento es la misma descrita en el artículo sobre la carga de documentos cifrados

Lo que la reconstrucción no puede devolverte

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 streams de objeto (§7.5.7) no son visibles individualmente para un escaneo de bytes, así que si un contenedor sobrevive pero su stream de referencias cruzadas (§7.5.8) no, los objetos que contiene no quedan indexados por la reconstrucción. Un fichero cuyo cuerpo estuviera realmente corrupto, en lugar de simplemente mal indexado, producirá cabeceras cuyo contenido ya no analiza. Y un fichero sin ninguna palabra clave trailer recuperable y sin catálogo legible no tiene nada a lo que anclar un árbol de documento, sin importar cuántas cabeceras de objeto se encontraran

Los números de objeto duplicados son el caso intermedio interesante. Un fichero actualizado incrementalmente contiene legítimamente varias generaciones del mismo número de objeto, y la cadena de referencias cruzadas superviviente es el único registro de cuál era la vigente. Una reconstrucción no tiene esa cadena, así que registra cada cabecera que ve en el orden del fichero y resuelve por número de objeto después. Normalmente gana la revisión más tardía, que normalmente es correcto, pero un documento que se actualizó y después se revirtió parcialmente puede volver sutilmente distinto de lo que describía el xref original. Los ficheros linearizados llevan la misma advertencia desde el otro lado: el layout de primera página y las tablas de pistas carecen de sentido una vez que el índice se regenera, así que un fichero reparado debería tratarse como un documento plano, no linearizado

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 respaldo es automático: PDFlibPas ejecuta el escaneo en crudo siempre que la cadena de referencias cruzadas no se puede leer, y también cuando cada entrada en uso afirma un offset cero, que es la firma de una tabla que se escribió pero nunca se rellenó. GetDocumentRepaired devuelve 1 cuando esa ruta se ejecutó, y merece la pena registrarlo en lugar de ignorarlo, porque un documento que cargó a través de la reconstrucción debería volver a guardarse en un fichero limpio en lugar de dejarse en un pipeline como si nada hubiera pasado. Guardarlo escribe una tabla de referencias cruzadas fresca y consistente, que es la corrección más barata posible para cada consumidor río abajo

La ruta de reconstrucción, el flag GetDocumentRepaired y el cargador en streaming mostrados aquí forman parte de la PDFlibPas Delphi PDF Library, junto con las APIs de análisis, renderizado y firma cubiertas en otros lugares de este blog