Article technique

Ramasse-miettes PDF en Delphi : mark and sweep

Supprimer une page d'un PDF ne supprime ni ses polices, ni ses images, ni ses flux de contenu. losLab PDF Library les récupère grâce à un collecteur mark-sweep qui parcourt le graphe d'objets en partant des racines du trailer et supprime tout objet indirect qu'aucune référence n'atteint. Il s'exécute lors d'une sauvegarde complète, il est désactivé par défaut, et il retourne le nombre d'objets supprimés

Pourquoi la suppression de pages PDF ne réduit-elle pas la taille du fichier ?

Parce que la suppression de page est une modification de références, non une opération de stockage. DeletePages(StartPage, PageCount) détache les objets de page de l'arbre des pages et répare les entrées de plan qui pointaient vers elles. Ce qu'elle ne peut pas faire, c'est décider que le programme de police, le flux de contenu et le XObject image utilisés par ces pages sont désormais morts, car au moment de la suppression rien dans le fichier n'enregistre qui d'autre pourrait encore pointer vers eux. Ces objets restent dans la liste des objets du document, et une sauvegarde complète les réécrit tous. Le résultat est la plainte qui ouvre la plupart de ces fils de support : un client supprime quatre-vingt-dix pour cent des pages, enregistre, et le fichier ne rétrécit que de deux pour cent. Pire, la fuite s'accumule. Charger, supprimer, enregistrer, recharger, supprimer à nouveau, enregistrer à nouveau, et le fichier grossit de façon monotone tandis que le nombre de pages diminue. C'est un problème différent de celui que résolvent le sous-ensemblage de polices et le sous-échantillonnage d'images, qui réduisent la taille des objets vivants. Ici, les objets ne sont pas trop gros. Ils ne font simplement plus partie du document

L'ensemble des racines est le trailer, pas l'arbre des pages

Le graphe d'objets PDF ne possède aucun champ de référence inverse. Le format ne définit ni compteur de références ni liste de pointeurs arrière, et les clés /Parent qui existent bel et bien appartiennent à des structures précises comme l'arbre des pages, pas au graphe d'objets dans son ensemble. Rien dans un objet indirect ne vous indique qui pointe vers lui, si bien que la question « quelqu'un utilise-t-il encore l'objet 47 » n'a qu'une seule réponse possible : parcourir le graphe en avant depuis une racine connue et voir si l'on y arrive. C'est pourquoi le collecteur de losLab PDF Library est un collecteur mark-sweep et non un schéma à compteur de références

Les racines proviennent du trailer du fichier (ISO 32000-1 §7.5.5). Trois clés les portent : /Root, le catalogue de document du §7.7.2 auquel se rattachent l'arbre des pages, les noms, les signets, l'AcroForm et les métadonnées ; /Info, le dictionnaire d'informations du document ; et /Encrypt, le dictionnaire de chiffrement. Les deux clés restantes du trailer sont des leurres. /ID est un tableau de deux chaînes d'octets, et /Prev est un décalage entier en octets vers la section de renvois précédente. Aucune des deux n'est une référence indirecte, donc aucune ne fournit de racine. losLab PDF Library met en file d'attente le dictionnaire du trailer dans son intégralité plutôt que trois clés nommées, ce qui ne coûte rien et maintient en vie toute extension privée du trailer

Le parcours lui-même est itératif plutôt que récursif. Lorsque le parcours rencontre une référence indirecte, il n'enregistre que le numéro d'objet et la génération, marque l'emplacement correspondant et le place dans une file FIFO au lieu de le déréférencer immédiatement, ce qui évite de charger la pile d'appels avec des arbres de pages profonds et de longues chaînes de signets, et empêche qu'un même objet soit décodé deux fois. Les dictionnaires directs, les tableaux et les dictionnaires de flux vont dans une seconde file protégée par un ensemble des éléments déjà visités, car les documents réels contiennent de véritables cycles : le /Parent d'une page pointe en retour vers son nœud de l'arbre des pages, et les éléments de signets s'enchaînent via /Prev et /Next dans les deux sens. Les numéros de génération font partie intégrante de la correspondance, ce ne sont pas des détails accessoires. Une référence ne se résout que lorsque le numéro d'objet et la génération concordent tous les deux ; une référence vers un numéro existant à une génération différente est traitée comme l'objet null que la spécification impose, jamais comme une arête vivante

Comment activer le ramassage des objets orphelins lors d'une sauvegarde ?

Le ramassage des objets orphelins est facultatif et fait partie de l'enregistrement des options de sauvegarde. Sa valeur par défaut est False, car le collecteur effectue une passe destructrice sur le graphe d'objets, et aucune bibliothèque ne devrait supprimer silencieusement des objets qu'un appelant ne lui a jamais demandé d'examiner

var
  Pdf: TPDFlib;
  Opt: TPDFlibSaveOptions;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
      Exit;
    Pdf.DeletePages(11, 490);          // keep the first ten pages

    FillChar(Opt, SizeOf(Opt), 0);
    Opt.CompressContent := True;
    Opt.CompressFonts := True;
    Opt.OptimizeContentStreams := True;
    Opt.PackObjectStreams := True;
    Opt.GarbageCollect := True;        // drop everything the pages left behind
    Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

Deux autres points d'entrée mènent au même collecteur. SetGarbageCollect(1) positionne l'indicateur sur le document sélectionné afin qu'un SaveToFile ordinaire en tienne compte, et GarbageCollectObjects exécute la passe immédiatement et retourne le nombre d'objets indirects orphelins supprimés. La forme immédiate est celle à utiliser lorsque l'on veut un nombre à journaliser ou à vérifier par assertion, et il vaut la peine de le contrôler, car une valeur de retour négative n'est pas un décompte

var
  Removed: Integer;
begin
  Pdf.DeletePages(11, 490);
  Removed := Pdf.GarbageCollectObjects;
  if Removed < 0 then
    // The graph could not be fully decoded. Nothing was swept and the
    // document is unchanged; save it without GC or reject the input.
    LogWarning('object graph incomplete, GC skipped')
  else
    LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;

Ce chemin d'échec compte plus qu'il n'y paraît. Les objets sont décodés à la demande, et un objet jamais décodé n'expose aucune référence du tout. Si le collecteur traitait un objet indécodable comme un nœud vide, il balaierait tout ce qui n'est atteignable que par son intermédiaire. C'est pourquoi le parcours force le décodage à chaque objet touché, et une seule erreur de décodage interrompt toute la passe avec un résultat négatif et laisse le document strictement identique octet pour octet. Balayer un graphe que l'on ne comprend que partiellement, c'est ainsi qu'un collecteur transforme un fichier endommagé en fichier détruit

Qu'est-ce qui fait échouer un collecteur PDF naïf ?

Deux détails posent problème, et tous deux échouent silencieusement plutôt que bruyamment. Le premier concerne les flux d'objets. Depuis PDF 1.5, un objet non-flux peut vivre compressé à l'intérieur d'un conteneur /ObjStm (§7.5.7), et son entrée de renvois est une entrée de type 2 nommant le conteneur ainsi qu'un index à l'intérieur de celui-ci. Un objet compressé n'est donc atteignable qu'à travers son conteneur. Marquez le membre, balayez le conteneur parce que rien ne le référençait en tant qu'objet du document, et vous avez écrit un fichier dont le xref pointe vers un objet qui n'existe plus. Le conteneur est un espace de stockage structurel, pas une donnée du document, si bien qu'il n'apparaît jamais comme une arête dans le graphe d'objets que vous parcourez. losLab PDF Library gère cela en détachant chaque membre compressé survivant de son conteneur source avant que les conteneurs ne disparaissent, après quoi la sauvegarde réempaquette les survivants dans de nouveaux flux d'objets. Le second détail concerne ce qu'un objet flux référence réellement. Les octets ne font pas partie du graphe. Un flux de contenu qui dessine du texte avec /F1 12 Tf nomme une police par son nom de ressource, et ce nom est résolu via le dictionnaire /Resources de la page, si bien que l'arête d'atteignabilité suit le chemin page → /Resources/Font → objet police, jamais à travers le contenu du flux. Les seules références qu'un flux apporte proviennent de son dictionnaire, où /Length, /Filter et /DecodeParms ont tous le droit d'être indirects. Un collecteur qui analyse les octets du flux à la recherche de références fait un travail coûteux pour rien ; un collecteur qui saute les dictionnaires de flux perd l'objet de longueur et corrompt le fichier

Que deviennent les numéros d'objets libérés

Ils deviennent des entrées libres, et ils ne sont pas réutilisés au cours de la même sauvegarde. Le balayage parcourt la liste des objets dans l'ordre décroissant afin que les suppressions restent stables du point de vue des index, reconstruit l'index de recherche une seule fois à la fin plutôt qu'après chaque suppression, et pour chaque objet supprimé enregistre le numéro dans la liste libre avec sa génération incrémentée de un, exactement comme le prescrit le §7.5.4 pour une entrée susceptible d'être réutilisée plus tard. Une génération déjà à 65535 y reste, ce qui marque ce numéro comme définitivement retiré. Les numéros d'objets ne sont délibérément pas compactés. Après une collecte, le fichier conserve des trous : l'objet 12 peut être libre alors que 13 et 14 sont utilisés, et le /Size du trailer continue de rapporter le numéro le plus élevé plus un, et non le nombre d'objets survivants. C'est légal et normal. Renuméroter économiserait une poignée d'octets dans la table de renvois et exigerait de réécrire chaque référence du document, ce qui est le genre de changement qui invalide silencieusement tout ce qui détient des numéros d'objets depuis l'extérieur. La taille récupérée provient des corps des objets, pas de la table xref

Quand il ne faut jamais lancer le collecteur

Jamais sur une mise à jour incrémentale. Le collecteur est réservé aux sauvegardes complètes et l'indicateur n'est tout simplement pas lu lorsque l'on ajoute au document, et cette restriction n'est pas une limitation à contourner. Une mise à jour incrémentale (§7.5.6) laisse les octets d'origine intacts et ajoute une nouvelle section de renvois chaînée à la précédente via /Prev. Chaque révision antérieure pointe toujours vers les objets qu'elle a toujours visés, si bien qu'un objet inatteignable dans la révision actuelle est parfaitement atteignable dans une révision plus ancienne. Le supprimer casserait toutes les révisions sauf la dernière, et le détail de ce mécanisme est traité dans l'article sur les mises à jour incrémentales et les sauvegardes en mode ajout. Le même raisonnement exclut le ramassage des objets orphelins sur un document signé, car la réécriture complète qui rend la collecte possible est précisément ce qui invalide la signature

Il convient aussi de préciser ce que la collecte n'est pas. Ce n'est pas un outil d'assainissement. Le collecteur supprime les objets que rien ne référence ; il n'a aucun avis sur le caractère sensible de leur contenu, et un objet toujours référencé reste ce qu'il était. Si l'objectif est de rendre une information irrécupérable plutôt que de réduire la taille du fichier, le graphe d'objets est le mauvais niveau d'action, et la rédaction au niveau des instructions et l'assainissement du document est le bon. Les deux se combinent bien dans cet ordre : rédiger et assainir d'abord, puis collecter, afin que les objets détachés par la rédaction quittent réellement le fichier. Le même tandem existe dans l'API de purge des ressources, où passer l'option de ramassage des objets orphelins fait exécuter une collecte après la purge et rapporte les orphelins supprimés dans OrphanObjectsRemoved

Une dernière habitude à adopter. Journalisez la valeur de retour de GarbageCollectObjects dans le traitement par lots qui effectue vos suppressions de pages, et observez-la pendant quelques semaines sur des documents réels. Un zéro sur un fichier que vous venez de réduire de moitié signifie qu'en amont, quelque chose détient encore une référence à laquelle vous ne vous attendiez pas, généralement une entrée d'arbre de noms, une destination de signet ou un champ AcroForm qui a survécu à la page à laquelle il était attaché. Le collecteur est le débogueur d'atteignabilité le moins coûteux que vous aurez jamais, car il répond à la question que le format PDF lui-même refuse de traiter

Le collecteur d'objets orphelins, l'enregistrement des options de sauvegarde et l'API de purge des ressources décrits ici font partie de losLab PDF Library pour Delphi et C++Builder, dont la page produit propose la référence complète du pipeline de sauvegarde, y compris l'interaction entre la collecte, le regroupement en flux d'objets et la linéarisation