Article technique

Rétrécir un PDF en Delphi : orphelins et dédup de ressources

Le PDFium Component pour Delphi (PDFiumPas) rétrécit des fichiers PDF avec trois API de réécriture sur TPdf : SaveAsReachable jette les objets que rien ne référence, SaveAsDeduplicated fusionne les polices, images et Form XObjects identiques octet pour octet, et SaveAsFlateRecompressed réencode les flux Flate existants au niveau de compression maximal. Chacune renvoie un enregistrement de rapport pour que vous voyiez ce qui a été retiré et combien d'octets elle a gagnés

Le déclencheur habituel est un ticket de support qui se lit "on a supprimé vingt pages et le fichier a grossi". Quelqu'un a ouvert un contrat, retiré une annexe, fusionné une page de couverture et enregistré. Chaque étape était une mise à jour incrémentale, donc les vieux objets sont toujours dans le fichier, et quand ce document est plus tard chargé et écrit avec FPDF_SaveAsCopy ils ressortent tels quels. PDFium sérialise ce qui siège dans sa table d'objets en mémoire ; il n'a pas d'appel public pour lister les objets indirects, en supprimer un, ou réécrire des références. Tout ce que montre cet article tourne donc dans la couche enveloppe PDFiumPas au-dessus des octets que PDFium produit

Pourquoi FPDF_SaveAsCopy garde-t-il les pages supprimées dans le fichier ?

FPDF_SaveAsCopy garde les objets orphelins parce que PDFium charge chaque entrée vivante de la table de références croisées dans son document, que quelque chose y pointe encore ou pas. ISO 32000-1 §7.5.6 décrit les mises à jour incrémentales comme l'apposition de nouvelles versions d'objets et d'une nouvelle section xref à la fin du fichier. Supprimer une page ne fait qu'éditer l'arbre de pages ; le dictionnaire de page, son flux de contenu, et toute police ou image utilisée par cette seule page restent listés comme objets en usage. Un enregistrement complet non incrémental ne change rien à cela, parce que du point de vue de la table d'objets ces objets sont parfaitement valides

Les mêmes restes apparaissent après des fusions de pages, des aplatissements de formulaires faits par d'autres outils, et des enregistrements de réparation de fichiers endommagés. Ils sont invisibles dans toute visionneuse et n'ajoutent rien que des octets. Sur des documents scannés, une seule image de page orpheline peut peser plusieurs mégaoctets

Marquage et balayage avec SaveAsReachable

TPdf.SaveAsReachable retire les orphelins avec une passe classique de marquage et balayage : elle laisse PDFium écrire une copie non incrémentale du document courant, parse cette copie en graphe d'objets, marque tout ce qui est atteignable depuis les racines, et écrit une seule révision classique qui ne contient que les objets marqués. Les objets au sein de flux d'objets sont matérialisés en objets indirects ordinaires en chemin, si bien que la sortie utilise toujours une table xref simple

Choisir les racines est là où une implémentation naïve se trompe. La graine évidente est le Catalogue nommé par le /Root du trailer. De là, la marche atteint l'arbre de pages, les ressources de pages, les polices, images, Form XObjects, l'AcroForm, l'arbre de structure, les signets, les arbres de noms, le contenu optionnel, les métadonnées XMP, les output intents et les fichiers embarqués. Mais le dictionnaire d'informations du document est référencé par l'entrée /Info du trailer (ISO 32000-1 §7.5.5), pas par le Catalogue. Ne semez que depuis /Root et vous supprimez en silence le Titre, l'Auteur et le Producteur de chaque fichier que vous traitez. PDFiumPas sème la marche avec /Root et /Info tous deux, et le trailer reconstruit ne reporte /Info que si cet objet a réellement survécu

Le marcheur est un parcours en profondeur avec une pile explicite et un ensemble de visités, si bien que les cycles de références (liens parents dans l'arbre de pages, pointeurs arrière /P d'annotations) ne peuvent pas le bloquer. Une référence vers un numéro d'objets déjà libre n'est simplement pas suivie plutôt que traitée comme erreur, ce qui garde la passe tolérante face à une entrée légèrement endommagée

Les numéros d'objets sont préservés, pas renumérotés

TPdf.SaveAsReachable garde chaque objet survivant à son numéro et génération d'objets d'origine. Les objets balayés sont écrits comme entrées libres dans la table xref (ISO 32000-1 §7.5.4), les trous entre numéros vivants sont remplis d'entrées libres, et l'objet 0 reste la tête de la liste libre. Comme rien n'est renuméroté, chaque référence qui survit reste correcte sans un seul octet de réécriture, et il n'y a pas de correspondance ancien-vers-nouveau à rater. Le coût est quelques lignes xref inutilisées, 20 octets chacune, un marché qui vaut d'être fait

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport porte SourceByteCount (la taille de la copie non incrémentale que PDFium a produite), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded et ErrorMessage. La surcharge nom de fichier écrit dans un fichier temporaire à côté de la cible et ne remplace la cible que si la réécriture a réussi, si bien qu'une exécution en échec ne laisse jamais derrière un PDF tronqué

Quels fichiers ces API refusent-elles ?

Les trois API de réécriture refusent les documents chiffrés et, par défaut, les signés. Une entrée chiffrée échoue avec Succeeded = False et un message d'erreur plutôt qu'une exception : une réécriture en révision unique ne peut pas garder les numéros d'objets, plages d'octets et clés par objet dont dépend le dictionnaire de chiffrement, donc la sortie serait illisible. Un document avec signatures numériques lève EPdfError sauf si vous passez AllowSignedDocument = True, parce qu'une réécriture complète invalide toujours la plage d'octets signée. Poser ce drapeau signifie que vous acceptez un fichier dont les signatures seront rapportées comme cassées ; c'est le bon choix pour une copie d'archive, jamais pour un contrat que vous vous apprêtez à renvoyer

Comment SaveAsDeduplicated décide-t-il que deux images sont identiques ?

TPdf.SaveAsDeduplicated traite deux ressources comme identiques seulement quand leurs dictionnaires, leurs sous-graphes atteignables entiers et leurs octets de flux bruts concordent tous. Elle exécute d'abord la même marche d'atteignabilité, puis prend les empreintes de chaque police, XObject image et Form XObject atteignables, fusionne chaque groupe de correspondances sur le membre au numéro d'objets le plus bas, et réécrit chaque référence vers ce survivant. Les doublons fusionnés deviennent des entrées xref libres comme les orphelins. C'est le correctif du rapport classique "logo embarqué 400 fois" produit par des outils de publipostage qui ré-embarquent les mêmes ressources à chaque page

L'empreinte est un hachage FNV-1a 64 bits calculé récursivement. Pour chaque candidate, le marcheur prend le corps d'objets, remplace chaque référence N G R par l'empreinte de l'objet vers lequel elle pointe, et hache le résultat. Cela rend le hachage indépendant de la numérotation d'objets : deux copies de la même police dont les descripteurs sont par hasard les objets 12 et 87 produisent quand même la même valeur. Les résultats sont mémoïsés pour que les sous-objets partagés soient hachés une fois, et un ensemble de visite renvoie une sentinelle fixe pour les arêtes de retour, pour que les graphes cycliques terminent

Le bug subtil siège dans la charge de flux. Pour trouver les références en sécurité, PDFiumPas scanne une copie de chaque objet dans laquelle les données de flux, chaînes et commentaires ont été effacées en espaces ; sinon une suite de données de pixels binaires qui ressemble par hasard à 12 0 R serait traitée comme référence. Hachez seulement cette copie aseptisée et deux images à dictionnaires identiques (mêmes /Width, /Height, /BitsPerComponent, /Filter, /Length) mais pixels différents s'écroulent en une seule empreinte, et la seconde photo de la page est remplacée en silence par la première. SaveAsDeduplicated appose les octets bruts entre stream et endstream aux données hachées, si bien que les pixels font partie de l'identité. Les candidates sont aussi groupées par catégorie avant comparaison, pour qu'une rare collision de hachage entre une police et une image ne puisse jamais les fusionner

La réécriture de références réutilise la même astuce de scan aseptisé à l'envers : les références sont localisées dans la copie effacée mais les octets sont copiés depuis le corps original, si bien que seule la structure de dictionnaires et tableaux est modifiée et que les charges binaires passent intactes

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

Lisez TPdfDedupReport attentivement. À côté des trois champs Merged*Count, il porte KeptObjectCount et PrunedObjectCount, et dans ce rapport PrunedObjectCount ne compte que les doublons fusionnés. Les objets inatteignables sont toujours jetés, mais ils ne sont pas inclus dans ce nombre. Si vous avez besoin du compte d'orphelins pour la journalisation, prenez-le dans une exécution séparée de SaveAsReachable. L'autre limite est que l'appariement est exact à l'octet sur le flux encodé : le même logo compressé par deux producteurs différents à deux niveaux Flate différents ne sera pas fusionné

Recompresser les flux Flate au niveau maximal

TPdf.SaveAsFlateRecompressed gonfle chaque flux FlateDecode atteignable et le regonfle au niveau de compression le plus haut, en gardant le résultat seulement quand il est plus petit et passe vos seuils. Beaucoup de producteurs écrivent du Flate à un niveau par défaut rapide, si bien que les flux de contenu et programmes de polices embarqués rétrécissent souvent davantage. Numéros et générations d'objets restent en place ; seuls la charge et son /Length direct changent

TPdfFlateRecompressOptions a deux champs et pas de constructeur par défaut, donc posez les deux. MinSavingBytes est le gain absolu minimum par flux, et MinSavingRatio est une fraction de la longueur originale (0.05 signifie 5 pour cent) ; un ratio de 0 désactive le test de ratio. Le flux reconstruit est écrit avec stream, CRLF, la nouvelle charge, CRLF et endstream, si bien que le résultat ne dépend pas des fins de ligne du fichier original

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // ignorez les menus gains
  Options.MinSavingRatio := 0.05;   // et tout ce qui est sous 5%
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

Les cas limites sont conservateurs par conception. Un flux dont le /Length est une référence indirecte est sauté, parce que la réécriture doit stocker un entier direct. Une charge qui échoue à gonfler compte dans FailedCount et est copiée inchangée. Un flux Flate qui ne passe pas les seuils compte dans SkippedCount. Les images DCT, JPX, JBIG2 et CCITT n'entrent jamais dans la liste de candidates, si bien que cette passe ne touche jamais la qualité JPEG. Pour des gains d'images avec perte, utilisez le rééchantillonnage adaptatif d'images dans PDFiumPas avant l'enregistrement final

Un piège de portabilité venu de l'écriture de ce test de seuil : la première ébauche comparait Single(Saving)/Single(OrigLen) avec Saving déclaré Int64. Delphi l'accepte, mais FPC 3.2.2 s'arrête avec Illegal type conversion: "Int64" to "Single", parce que dans FPC cette syntaxe est un transtypage plutôt qu'une conversion numérique. Le simple Saving / OrigLen est une vraie division dans les deux compilateurs et se compare directement contre un Single, donc les conversions étaient tout bonnement inutiles

Dans quel ordre faire tourner les trois API ?

Lancez SaveAsDeduplicated d'abord, rechargez la sortie, puis lancez SaveAsFlateRecompressed. La déduplication inclut déjà le balayage d'atteignabilité, si bien qu'une passe SaveAsReachable séparée devant elle est redondante ; gardez SaveAsReachable pour les travaux où vous ne voulez que le nettoyage d'orphelins et un compte de suppression exact. Recompresser en second veut dire que le travail coûteux gonfle-et-regonfle tourne une fois par flux survivant au lieu d'une fois par doublon. La passe de recompression parcourt aussi le graphe depuis /Root et /Info à nouveau, ce qui nettoie les descripteurs ou fichiers de polices vers lesquels ne pointait plus qu'une police doublon fusionnée

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // élagage + fusion

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // regonflez les survivants
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

Si vous appliquez aussi des rédactions, faites-le avant tout ceci, puisque retirer physiquement du contenu est ce qui crée de nouveaux orphelins au départ ; l'article sur la rédaction au niveau opérateur et l'élagage de ressources couvre le côté rédaction. Et rechargez toujours le fichier final comme vérification de bon sens dans votre pipeline : un compte de pages qui correspond à la source est une assurance bon marché avant d'écraser quoi que ce soit auquel un client tient

FAQ

SaveAsReachable renumérote-t-il les objets ou change-t-il les références ?

Non. TPdf.SaveAsReachable garde chaque objet survivant à son numéro et génération d'origine et marque les objets retirés comme entrées xref libres, si bien qu'aucune référence ne doit être réécrite

SaveAsDeduplicated peut-il fusionner deux images différentes par erreur ?

Pas par le seul appariement des dictionnaires. L'empreinte inclut les octets de flux bruts et le sous-graphe référencé complet, et les candidates ne sont comparées qu'au sein de la même catégorie (police, image, Form XObject)

Ces API gardent-elles mes signatures numériques valides ?

Non. Chaque API exécute une réécriture complète, qui casse la plage d'octets signée, si bien que les fichiers signés sont refusés sauf si vous passez AllowSignedDocument = True et acceptez des signatures invalides dans la sortie

Les trois API sont livrées avec le PDFium Component pour Delphi et C++Builder, aux côtés des fonctions de rédaction et de rééchantillonnage d'images couvertes ailleurs sur ce blog