Lorsqu'une table de renvois PDF est inutilisable, la solution consiste à l'ignorer entièrement et à la reconstruire à partir du corps du fichier. La PDFlibPas Delphi PDF Library procède ainsi grâce à un scanner de jetons en une seule passe qui enregistre chaque en-tête d'objet indirect authentique qu'il rencontre, puis récupère le dictionnaire trailer et confie la table reconstruite au chargeur normal
Qu'est-ce qui casse en premier quand un PDF est endommagé ?
La table de renvois est la partie la plus fragile d'un PDF, car c'est la seule à stocker des décalages absolus en octets. ISO 32000-1 §7.5.4 définit ces entrées comme des décalages à dix chiffres depuis le début du fichier, et §7.5.5 place le mot-clé startxref près de la fin, pointant vers la table elle-même. Chacun de ces nombres est invalidé par la moindre modification qui décale des octets. Une session FTP menée en mode texte qui a converti les CRLF, un téléchargement tronqué, un secteur devenu défectueux sur un disque partagé, un outil de traitement par lots qui a ajouté des données sans écrire correctement une mise à jour incrémentale : tous laissent les données d'objets parfaitement lisibles tandis que l'index pointe vers du bruit
C'est pourquoi la boîte de dialogue « le fichier est endommagé et va être réparé » est si fréquente. Les octets sont presque toujours encore là. Ce qui a disparu, c'est la carte. La reconstruction n'est donc pas une récupération judiciaire de données perdues, c'est la reconstruction d'un index qui peut être dérivé du corps du fichier, et elle réussit bien plus souvent qu'on ne l'imagine, car le contenu coûteux — arbres de pages, polices, images — reste intact
Pourquoi la recherche de N 0 obj produit-elle de faux résultats ?
Une reconstruction naïve recherche dans les octets bruts le motif « entier, entier, obj » et enregistre chaque occurrence. Elle en trouve trop. PDF est un format conteneur, et trois zones d'un fichier sont opaques à la grammaire des objets : les commentaires (§7.2), les chaînes (§7.3.4) et les données de flux (§7.3.8). N'importe laquelle peut contenir des octets qui se lisent exactement comme un en-tête d'objet, sans en être un. Une légende dans une chaîne littérale, un commentaire de débogage oublié, ou deux mégaoctets de sortie Flate ou DCT produiront tous, sans peine, quelque chose qui ressemble à 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;
Chaque fausse entrée coûte double. Elle pollue la table reconstruite avec un numéro d'objet qui n'existe pas, et elle peut masquer un objet réel portant le même numéro qui apparaît plus loin dans le fichier. PDFlibPas ne pratique donc aucune recherche par motif. Il tokenise, ce qui signifie qu'il sait toujours si les octets sous le curseur sont du code ou de la charge utile, et la charge utile est ignorée sans jamais être interprétée
Une passe unique par blocs de 64 Kio
PDFlibPas analyse le fichier entier une seule fois, par blocs de 64 Kio, avec une machine à états construite sur les règles de jetons de l'ISO 32000-1 §7.2 et la syntaxe des objets indirects du §7.3.10. Un jeton se termine à un espace blanc ou à l'un des caractères délimiteurs, et un en-tête d'objet n'est enregistré que lorsqu'une séquence complète composée d'un numéro d'objet positif, d'un numéro de génération non négatif et du mot-clé nu obj a été rencontrée. Le décalage enregistré est le début du jeton du numéro d'objet, ce qui est précisément ce vers quoi une entrée de renvois doit pointer, et non la position du mot-clé 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;
Le détail important est que l'état du jeton et l'état de chaîne survivent à une frontière de bloc. Un en-tête qui chevauche la limite des 65536 octets est tout de même reconnu, car le jeton partiel, la paire d'entiers en attente et les indicateurs d'état « à l'intérieur d'une chaîne » se reportent tous sur le bloc suivant. Les tampons sont fixes : 64 Kio pour l'analyse, 32 octets pour le plus long jeton pouvant compter, et les seuls tableaux qui grandissent avec le fichier sont les listes de numéros d'objet, de numéros de génération et de décalages 64 bits, proportionnelles au nombre réel d'objets plutôt qu'à la taille du fichier. En pratique, l'analyse effectue des lectures séquentielles et au plus deux positionnements explicites sur l'ensemble du document, ce qui la rend viable sur les fichiers de plusieurs centaines de mégaoctets évoqués dans l'article sur la fusion et le découpage en accès direct de gros PDF
Pourquoi un flux ne peut-il pas être supposé se terminer à endstream ?
Parce que les données de flux sont des octets arbitraires, et que des octets arbitraires peuvent former endstream par hasard. Un flux qui commence après le mot-clé stream doit être ignoré comme donnée opaque jusqu'à ce qu'il se termine véritablement, mais la première occurrence du mot-clé de fermeture n'est qu'une candidate. PDFlibPas résout cela en exigeant une corroboration : un jeton endstream n'est accepté comme véritable fin de flux que lorsque le prochain jeton non blanc est un endobj autonome, la séquence exigée par le §7.3.8 autour d'un objet flux. Une occurrence fortuite à l'intérieur de données compressées n'a presque jamais cette suite, si bien que le scanner reste à l'intérieur du flux et continue. Deux règles plus modestes comptent tout autant. Le mot-clé stream ne fait entrer en état de flux que lorsqu'il s'agit d'un mot-clé nu, si bien qu'un objet nom tel que /stream dans un dictionnaire ne le déclenche jamais. Et un jeton obj ou trailer n'est honoré que lorsque le jeton n'a pas dépassé la limite de 32 octets et ne commençait pas par une barre oblique. Sans ces deux garde-fous, un dictionnaire de ressources aux mauvais noms de clés suffirait à faire dérailler l'analyse, exactement la classe d'entrée hostile couverte dans les notes sur l'analyse sûre de PDF non fiables
Trouver la véritable fin du dictionnaire trailer
Récupérer les objets n'est que la moitié du travail, car le chargeur a encore besoin d'un trailer pour trouver /Root. PDFlibPas retient les 64 dernières positions du mot-clé trailer trouvées pendant l'analyse et les valide à rebours, de la plus récente à la plus ancienne, si bien que le trailer utilisable le plus récent l'emporte, et un mot-clé isolé qui n'est pas suivi d'un dictionnaire échoue simplement à la validation et laisse la place au candidat précédent. Chaque candidat est lu avec une limite de 1 Mio, et la fin du dictionnaire est localisée en suivant la profondeur imbriquée de << et >>, ainsi que les échappements de chaînes littérales, les chaînes hexadécimales et les commentaires
// 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
Le suivi de la profondeur n'est pas académique. Un trailer tronqué qui perd /Encrypt transforme un document chiffré récupérable en un document impossible à ouvrir, et perdre /Info ou un sous-dictionnaire personnalisé fait silencieusement disparaître des métadonnées dont un système en aval peut dépendre. Si le fichier est chiffré, le trailer récupéré est ce qui permet au chemin normal d'authentification de s'exécuter, et la sémantique de nouvelle tentative est la même que celle décrite dans l'article sur le chargement de documents chiffrés
Ce que la reconstruction ne peut pas vous rendre
La reconstruction est un effort de meilleur niveau possible, et être honnête sur ses limites fait partie du travail de la livrer. Trois cas échouent totalement. Les objets empaquetés dans des flux d'objets (§7.5.7) ne sont pas individuellement visibles par une analyse d'octets, donc si un conteneur survit mais que son flux de renvois (§7.5.8) ne survit pas, les objets qu'il contient ne sont pas indexés par la reconstruction. Un fichier dont le corps a réellement été corrompu, plutôt que simplement mal indexé, produira des en-têtes dont le contenu ne s'analyse plus correctement. Et un fichier sans mot-clé trailer récupérable ni catalogue lisible n'a rien à quoi ancrer un arbre de document, quel que soit le nombre d'en-têtes d'objets trouvés
Les numéros d'objets dupliqués constituent le cas intermédiaire intéressant. Un fichier mis à jour de façon incrémentale contient légitimement plusieurs générations d'un même numéro d'objet, et la chaîne de renvois survivante est le seul enregistrement indiquant laquelle était actuelle. Une reconstruction ne dispose pas de cette chaîne, elle enregistre donc chaque en-tête rencontré dans l'ordre du fichier puis résout par numéro d'objet ensuite. En général, c'est la révision la plus récente qui l'emporte, ce qui est généralement correct, mais un document mis à jour puis partiellement annulé peut revenir subtilement différent de ce que décrivait le xref d'origine. Les fichiers linéarisés portent la même mise en garde sous un autre angle : la mise en page de première page et les tables d'indices deviennent dénuées de sens une fois l'index régénéré, si bien qu'un fichier réparé doit être traité comme un document ordinaire, non linéarisé
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;
Le repli est automatique : PDFlibPas exécute l'analyse brute dès que la chaîne de renvois ne peut pas être lue, ainsi que lorsque toutes les entrées en usage revendiquent un décalage zéro, ce qui est la signature d'une table écrite mais jamais remplie. GetDocumentRepaired retourne 1 lorsque ce chemin s'est exécuté, et il vaut la peine de le journaliser plutôt que de l'ignorer, car un document chargé via reconstruction devrait être réenregistré dans un fichier propre plutôt que laissé tel quel dans un pipeline comme si de rien n'était. L'enregistrer produit une table de renvois fraîche et cohérente, ce qui est la correction la moins coûteuse possible pour tout consommateur en aval
Le chemin de reconstruction, l'indicateur GetDocumentRepaired et le chargeur en flux présentés ici font partie de la PDFlibPas Delphi PDF Library, aux côtés des API d'analyse, de rendu et de signature couvertes ailleurs sur ce blog