Vous avez un PDF sur le disque, un client l’a scanné à partir d’une pile de factures, et votre travail consiste à ressortir les images de page sous forme de bitmaps pour un passage OCR. Vous chargez le fichier, vous repérez les XObjects image, puis vous découvrez la partie dont personne ne vous avertit : les octets de ces flux ne sont pas des pixels. Ce sont un codestream JPEG, ou un blob JPEG 2000 compressé en ondelettes, ou un ruban fax Group 4, ou un raster indexé derrière une palette et un filtre Flate. L’objet image connaît sa largeur et sa hauteur, mais les échantillons réels restent enfermés dans le filtre choisi par le producteur. Obtenir un TBitmap exploitable signifie annuler ce filtre, et PDF offre à peu près huit manières différentes d’enfermer les octets
C’est précisément le manque que ExtractLoadedImage comble dans HotPDF, le composant PDF VCL natif pour Delphi et C++Builder. Il énumère les XObjects image d’un document que vous avez chargé, indique ce qu’est chacun d’eux, puis décode ceux qu’il peut en un bitmap 24 bits. L’élément intéressant n’est pas la surface de l’API, qui tient en trois méthodes. C’est la raison pour laquelle un chemin de décodage séparé doit exister, et ce qu’il peut ou non reconvertir en pixels
Pourquoi les images chargées ne sont pas déjà décodées
Le chargeur de HotPDF est bâti autour d’une fidélité en passage direct. Quand vous appelez LoadFromFile, les flux d’image sont conservés exactement tels qu’ils apparaissent dans le fichier source : le filtre d’origine, les octets compressés d’origine, le dictionnaire d’origine. C’est volontaire. Le but d’un chargement de document est le plus souvent de copier des pages, fusionner des fichiers, appliquer un tampon, modifier des autorisations et réécrire le résultat, et pour tout cela la solution la moins coûteuse et la plus sûre consiste à laisser chaque flux d’image intact. Décoder chaque image en raster au chargement gaspillerait mémoire et CPU pour un travail dont la plupart des appels n’ont jamais besoin, et réencoder à l’enregistrement dégraderait des images qui auraient dû être copiées à l’identique
La conséquence est que le graphe d’objets chargé ne porte aucun pixel. Un XObject image dont le /Filter est /DCTDecode contient des octets JPEG ; HotPDF n’a jamais exécuté de décodeur JPEG dessus, parce que rien dans le chemin de copie et de réécriture n’en avait besoin. Donc, lorsque vous voulez réellement des pixels, l’API d’extraction doit faire le décodage elle-même, depuis zéro, pour le filtre que cette image particulière utilise. C’est la même raison pour laquelle les codecs de la voie d’encodage sont indépendants du chargeur : l’article sur ajouter des images JPEG 2000 à des PDF dans Delphi décrit comment le moteur JPX se branche sur la création, et ce moteur n’a tout simplement pas été relié au chemin de lecture avant que l’API d’extraction en ait besoin
L’API à trois méthodes
La surface est réduite. GetLoadedImageCount renvoie le nombre de XObjects image contenus dans le document chargé. GetLoadedImageInfo remplit un enregistrement descripteur pour l’un d’eux à partir de son index. GetLoadedImage renvoie le bitmap décodé, ou nil lorsqu’il ne peut pas décoder cette image. L’énumération est basée sur un index et reste stable pour un chargement donné : en interne, elle parcourt la table des objets indirects et collecte chaque flux dont le /Subtype résout à /Image, de sorte que l’index passé à GetLoadedImageInfo est le même que celui passé à GetLoadedImage
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I, Count: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
Exit;
Count := Pdf.GetLoadedImageCount;
for I := 0 to Count - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if not Info.Decodable then
Continue; // filter or colour space not supported
Bmp := Pdf.ExtractLoadedImage(I);
if Bmp <> nil then
try
Bmp.SaveToFile(Format('img_%d.bmp', [I]));
finally
Bmp.Free; // caller owns the bitmap
end;
end;
finally
Pdf.Free;
end;
end;
// Triage every image before committing to a decode.
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
I: Integer;
begin
// ... Pdf loaded ...
for I := 0 to Pdf.GetLoadedImageCount - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if Info.Decodable then
// ExtractLoadedImage(I) will return a TBitmap
else
// unsupported filter/colour space: log the object and skip
Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
[I, Info.ObjectNumber, Info.Width, Info.Height,
String(Info.Filter), String(Info.ColorSpace)]));
end;
end;
Deux détails de contrat comptent ici. D’abord, le TBitmap renvoyé vous appartient et vous devez le libérer ; le document ne le met pas en cache et n’en devient pas propriétaire. Ensuite, vérifiez Decodable avant d’appeler, puis vérifiez le résultat par rapport à nil après l’appel. La méthode ne lève pas d’exception sur un filtre non pris en charge, elle renvoie nil, et un nil silencieux dans une boucle de traitement est exactement le genre de chose qui fait disparaître une page dans un lot de mille sans que personne ne s’en rende compte
Lire le descripteur avant de décoder
THPDFLoadedImageInfo vous dit ce qu’est une image sans engager un décodage complet. Ses champs viennent directement du dictionnaire image : Width et Height en échantillons, BitsPerComponent, ColorSpace et ComponentCount décrivant l’interprétation après décodage (1 pour le gris, 3 pour RGB, 4 pour CMYK), FilterName comme compression nommée, IsMask pour les masques pochoir, ObjNum pour l’objet indirect sous-jacent, et Decodable
Ce dernier indicateur est le plus honnête. Decodable vaut True seulement quand la version en cours peut réellement transformer cette combinaison précise de filtre et d’espace colorimétrique en bitmap. Il encode la vraie matrice de prise en charge, pas un espoir : une image dont le FilterName n’est pas compris par la version en cours renvoie False, et vous pouvez vous en servir pour journaliser, ignorer ou retomber sur l’extraction du flux brut par vous-même. Traitez-le comme une précondition, pas comme une suggestion
Un détail d’implémentation piège les gens qui construisent le descripteur à la main. THPDFLoadedImageInfo contient deux champs AnsiString, FilterName et ColorSpace. Ce sont des types gérés avec comptage de références, donc le réflexe qui consiste à remettre un enregistrement à zéro avec FillChar est faux ici : cela écrase la référence de chaîne sans la décrémenter, ce qui fuit ou corrompt. HotPDF initialise l’enregistrement champ par champ exactement pour cette raison, et si vous reproduisez ce modèle dans votre propre code, faites la même chose
Un répartiteur, huit chemins de filtre
Si cette fonctionnalité a été livrée en plusieurs versions plutôt qu’en une seule, c’est parce que PDF n’a pas de format image. Il a des filtres, et le §8.9.5 de l’ISO 32000-1 permet à un XObject image d’en nommer n’importe lequel dans /Filter, tandis que l’interprétation des échantillons est gouvernée séparément par /BitsPerComponent, /ColorSpace et un tableau /DecodeParms facultatif. ExtractLoadedImage lit le nom du filtre et route vers un décodeur dédié pour chaque cas. L’ensemble pris en charge, construit de la version v2.229 à v2.231, couvre désormais huit chemins distincts
- Raw rasters (FlateDecode, LZWDecode, or no filter) in 8-bit DeviceRGB or DeviceGray. The bytes inflate to a packed raster, and the only transform is a channel swap, covered below
- DCTDecode (JPEG). The codestream is handed to the VCL's
TJPEGImage, which resolves geometry and colour, and the result is assigned into a 24-bit bitmap - JPXDecode (JPEG 2000). Decoded through the OpenJPEG backend, the same engine described in the JPEG 2000 article, with high-bit-depth components resampled down to 8 bits
- Indexed colour. The palette is read from the
[/Indexed base hival lookup]array and each sample is expanded through the lookup table to true colour - DeviceCMYK. Four-channel samples are converted to RGB with the standard ink-on-white formula
- Sub-8-bit DeviceGray and Indexed at 1, 2, or 4 bits per component, unpacked sample by sample and scaled to the 0–255 range
- CCITTFaxDecode, the Group 3 and Group 4 fax filters, decoded by a dedicated T.4/T.6 backend
- JBIG2Decode, the high-ratio bilevel filter, decoded through the registered JBIG2 backend that the native JBIG2 compression article covers from the encode side
Tout finit au même endroit : un bitmap BGR 24 bits, parce que c’est ce que stocke nativement un TBitmap VCL et ce que tout consommateur en aval attend
Les transformations qui modifient les pixels sans bruit
Deux de ces chemins impliquent une transformation qu’il est facile de rater subtilement, et qui mérite d’être comprise même si vous ne touchez jamais le décodeur vous-même. La première est l’échange d’ordre des couleurs. Un raster PDF DeviceRGB stocke les échantillons dans l’ordre rouge-vert-bleu, ligne supérieure en premier. Un scanline 24 bits VCL les stocke dans l’ordre bleu-vert-rouge. Donc décoder une simple image RGB n’est pas un memcpy ; les premier et troisième octets de chaque pixel sont échangés en entrant dans le scanline. Si vous inversez cela, les rouges et les bleus se permutent, ce qui paraît acceptable sur une image de test en niveaux de gris et catastrophique sur une image couleur. Quant à l’ordre des lignes, il se propage directement : les rasters PDF orientés du haut vers le bas s’alignent avec TBitmap comme première ligne visuelle en haut, donc aucun retournement vertical n’est nécessaire
La seconde concerne le CMYK. Les images PDF DeviceCMYK transportent quatre encres, et la conversion vers RGB est un calcul par canal, pas une table de correspondance : chaque canal de sortie vaut 255 - min(255, C + K). C’est une approximation de périphérique, pas une conversion gérée par couleur via un profil ICC, donc le résultat est assez bon pour l’affichage et la ré-rasterisation, mais ce n’est pas la bonne voie si vous avez besoin d’une couleur exacte pour l’impression. Si votre flux exige de la fidélité, considérez le bitmap extrait comme un aperçu et gardez le flux CMYK original pour le pipeline géré en couleur
Le chemin Indexed cache son propre piège d’analyse. La palette d’un espace colorimétrique /Indexed peut être stockée sous forme de chaîne littérale ou de chaîne hexadécimale, et HotPDF conserve la valeur d’une chaîne hexadécimale comme le texte hexadécimal lui-même, pas comme des octets déjà décodés. Donc, lorsque la palette est une chaîne hexadécimale, la table de recherche doit d’abord passer par une conversion hex vers octets ; une chaîne littérale est déjà du binaire brut. Omettre cette branche et une image indexée à quatre couleurs ressort en charabia, parce que chaque entrée de palette est lue avec le mauvais alignement d’octets
Chaînes de filtres : le dernier filtre est propre à l’image
Un nom /Filter unique est le cas simple. PDF autorise aussi une chaîne de filtres, dans laquelle le flux a été passé successivement par plusieurs filtres, listés dans l’ordre dans un tableau /Filter tel que /ASCII85Decode ou /LZWDecode (ISO 32000-1 §7.4). La sémantique est précise : à l’encodage les filtres s’appliquent de gauche à droite, donc au décodage il faut les annuler de droite à gauche, et le dernier filtre du tableau est celui qui définit réellement le format de l’image. Les filtres qui précèdent ne sont que des encodages de transport autour de lui
L’extracteur traite cela en pelant les couches. Avant qu’un décodeur d’image n’entre en jeu, chaque filtre de la chaîne sauf le dernier est appliqué pour produire l’entrée attendue par le filtre final, puis seulement ensuite le routage se fait sur ce dernier filtre. Ainsi, /ASCII85Decode autour d’un JPEG est d’abord désencodé en ASCII85, puis envoyé vers le chemin JPEG ; /ASCIIHexDecode autour d’un raster brut est d’abord converti, puis traité par le chemin raster. C’est ce qui permet aux huit décodeurs de rester simples. Aucun n’a à connaître les enveloppes de transport ASCII85 ou hexadécimal, parce qu’au moment où un décodeur voit les octets, ces enveloppes ont déjà disparu. Cela signifie aussi qu’une chaîne dont le dernier filtre n’est pas pris en charge échoue proprement au stade du dispatch, et non à mi-chemin du décodage
Où s’arrête l’extraction, et quoi faire ensuite
Restez honnête sur les limites. Une image dont le filtre final n’appartient pas à l’ensemble pris en charge renvoie nil, tout comme une image dont l’espace colorimétrique n’est pas interprétable par la version en cours. Les masques souples et l’alpha ne sont pas reconstruits dans le bitmap ; vous obtenez l’image de base, pas le résultat composité. Les profondeurs au-delà de 8 bits pour JPEG 2000 sont ramenées vers le bas, ce qui est volontairement destructif et inadapté si vous réarchivez plutôt que si vous affichez. Et un masque d’image, pochoir 1 bit sans couleur propre, est bien décrit par le descripteur mais reste quelque chose de différent d’une image picturale ; si vous le decodez en vous attendant à une photo, vous serez surpris
Quand l’extraction ne suffit pas, le flux brut est toujours là dans le graphe d’objets chargé, filtre compris, et vous pouvez le sortir octet par octet pour le confier à un codec spécialisé de votre cru. C’est le repli que le design en passage direct préserve volontairement : les octets d’origine ne sont jamais jetés, donc le pire des cas consiste à les décoder vous-même plutôt qu’à découvrir que les données ont disparu. Pour la plupart des travaux réels, toutefois, les huit filtres pris en charge couvrent ce que les scanners, suites bureautiques et moteurs de rapports émettent réellement, et une boucle sur GetLoadedImageCount avec un garde Decodable transforme un PDF chargé en dossier de bitmaps en quelques lignes
L’API d’extraction d’images chargées, avec l’ensemble des filtres de décodage décrits ici, est fournie dans le HotPDF Component pour Delphi et C++Builder