Article technique

Extraction de texte, images et polices PDF en Delphi

Sortir texte, images et polices d'un PDF existant ressemble à un problème résolu jusqu'à ce que vous y passiez un vrai corpus. Pointez un indexeur de recherche sur quarante mille fichiers clients et les avaries se rangent en quelques tas reconnaissables. Des mots se collent parce que personne n'a dit à l'extracteur à partir de quelle largeur un écart compte comme une espace. D'autres pages reviennent en charabia parce qu'une police sous-ensemblée ne porte aucune table reliant ses codes de glyphes à de vrais caractères. Et "le logo de la société" se révèle être neuf objets image distincts empilés derrière un masque doux. Rien de tout cela n'est un bug de la bibliothèque. C'est la différence entre appeler une fonction d'extraction et comprendre ce que cette fonction peut ou ne peut pas récupérer des octets présents sur le disque

losLab PDF Library, édition Pascal, donne au code Delphi et C++Builder plusieurs façons de lire chacun de ces trois flux, et les niveaux diffèrent par ce qu'ils garantissent. L'astuce consiste à faire correspondre le niveau à la tâche : un index de recherche, un relecteur de caviardage et une passe de preflight PDF/A veulent tous des choses différentes de la même page, et choisir le mauvais appel gaspille de l'effort ou produit une sortie à laquelle vous ne pouvez pas vous fier

Les niveaux d'extraction de texte et ce que chacun promet

GetPageText prend une valeur d'options de 0 à 8, et ce nombre choisit un moteur plutôt qu'un format. Les valeurs 0 à 2 lancent une passe légère, très bien pour un aperçu rapide. Les valeurs 3 à 8 passent par le moteur sensible à la mise en page, qui reconstruit lignes et espacement à partir de la position réelle des glyphes sur la page. Dans cette plage, les variantes comptent : 4 et 6 découpent la sortie en mots, 5 et 6 émettent les largeurs par glyphe, et 7 renvoie du texte brut, police, couleur et métadonnées de bloc délibérément abandonnées. L'option 7 est celle qu'il faut donner à un index de recherche, puisque l'index veut des mots et rien d'autre

Aucun réglage d'options ne peut sauver un document qui n'a jamais porté l'information au départ. Le PDF fait correspondre des codes de caractères à des formes de glyphes, et la seule chose qui remonte de ces codes vers du texte lisible est la table ToUnicode CMap d'une police (ISO 32000-1 §9.10). Quand une police sous-ensemblée est livrée sans elle, tout extracteur est bloqué. Cette bibliothèque, le copier-coller d'une visionneuse, une boîte à outils concurrente : tous en sont réduits à deviner d'après les noms de glyphes ou à ne rien renvoyer. La réponse pratique est la détection, pas l'héroïsme. Notez la page comme peu fiable et envoyez-la à l'OCR, car indexer le charabia en silence est pire que reconnaître que vous ne savez pas lire

Diagramme des niveaux d'extraction de texte PDF en Delphi : les options GetPageText de 0 à 8 mènent à une passe légère ou au moteur sensible à la mise en page, et les pages dont les polices sous-ensemblées manquent de table ToUnicode CMap partent vers l'OCR
Les valeurs d'option 0 à 8 de GetPageText choisissent entre une passe d'aperçu légère et le moteur sensible à la mise en page, avec l'option 7 réservée à l'indexation de recherche et les tables ToUnicode manquantes détournées vers l'OCR

Pour les cas que les options plates ne couvrent pas, tokenisation sur mesure, analyse forensique de flux de contenu, entonnoir de texte bâti selon vos propres règles, le décodeur est disponible une couche plus bas. TPDFExtractor se construit sur le dictionnaire de ressources d'une page et sa collection de polices. Sa méthode ExtractTextW repasse les opérations de texte brutes du flux de contenu par la même machinerie de polices pour récupérer l'Unicode, et son événement OnFindObject vous remet chaque objet au fil du flux. La plupart du code n'a jamais besoin de descendre si bas. Les applications qui en ont besoin sont celles qui se félicitent que cette couche soit publique plutôt qu'enfouie

Les blocs positionnés : unité des résultats de recherche et de la relecture de caviardage

Le texte brut vous dit ce que la page raconte. Tôt ou tard, un produit a aussi besoin de savoir où elle le raconte, pour surligner un résultat de recherche, encadrer un candidat au caviardage ou ancrer une annotation au bon endroit. ExtractPageTextBlocks renvoie un handle vers une liste de segments de texte, et chaque segment porte son texte, sa boîte englobante, ainsi que le nom et la taille de police dans lesquels il a été composé :

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

Un détail de ce domaine fait trébucher les intégrations plus que tout autre. SetTextExtractionArea, SetTextExtractionWordGap et SetTextExtractionOptions sont un état au niveau document qui persiste, pas des arguments passés à chaque appel. Configurez une restriction de zone pour une fonctionnalité, disons ne lire que la bande d'en-tête pour classer un document, et elle tronque en silence toutes les extractions qui suivent sur le même handle, y compris les niveaux GetPageText sensibles à la mise en page auxquels vous recourez plus tard. Réinitialisez l'état d'extraction entre tâches logiques, ou donnez à chaque tâche son propre handle de document

Le seuil d'écart entre mots est le levier pour ce premier tas d'avaries, les mots qui se collent. SetTextExtractionWordGap indique au moteur de mise en page quel espace horizontal, mesuré au regard de l'espacement de glyphes propre à la page, sépare un mot du suivant. Un tableau dense veut un écart plus petit qu'une page marketing aérée, si bien qu'un seuil réglé par classe de document bat une constante globale unique. Il persiste sur le document comme le reste de l'état d'extraction, alors prévoyez de le poser délibérément plutôt qu'une fois pour toutes puis de l'oublier

Diagramme montrant l'état d'extraction PDF au niveau document en Delphi persistant d'un appel à l'autre sur un même handle jusqu'à réinitialisation, ce qui évite la troncature silencieuse des extractions ultérieures
Zone d'extraction, écart entre mots et réglages d'options persistent sur le handle du document, si bien qu'une région délimitée pour une fonctionnalité tronque discrètement toute extraction ultérieure jusqu'à ce que l'état soit réinitialisé ou le handle remplacé

Images : flux originaux, pas captures d'écran

La mauvaise manière de sortir les images d'un PDF est de rendre la page et de la rogner. Cela rééchantillonne les pixels, cuit toute rotation dans l'image et jette ce qu'était l'original. GetPageImageList énumère au contraire les ressources image réellement référencées par la page, et chaque élément rend ses propriétés et ses données originales intactes :

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

Consultez GetImageListItemFormatDesc avant de supposer quoi que ce soit d'un élément, car ce qu'une page référence est rarement une jolie image par visuel. Un masque doux apparaît comme une entrée séparée. Le même XObject se répète souvent sur de nombreuses pages, alors dédupliquez par empreinte de contenu avant d'archiver un export "toutes les images", sinon vous écrirez cent fois le même logo. Les JPEG CMJN réclament une gestion des couleurs en aval, faute de quoi ils s'affichent inversés dans les visionneuses qui prennent les canaux au pied de la lettre. Quand vous voulez un inventaire à l'échelle du document plutôt que page par page, FindImages associé à SetFindImagesMode balaie tout le fichier en une passe

Une limite mérite d'être soulevée auprès des parties prenantes avant que quiconque n'écrive des critères d'acceptation : l'extraction d'images ne rend que des ressources raster. Un logo ou un graphique dessiné en tracés vectoriels n'est pas une image au sens des ressources et ne figurera dans aucune liste d'images, si nettement qu'il se lise comme une illustration à l'écran. Quand l'exigence est vraiment de livrer ce graphique en fichier, l'approche honnête est de rendre la région de page en bitmap, ce qui est une opération différente avec une fidélité différente. Les deux sortes de sortie n'ont pas leur place dans le même dossier d'export sans une étiquette disant laquelle est laquelle

Comparaison entre rendre une page PDF Delphi pour récupérer des images et extraire les flux d'images originaux avec GetPageImageList, avec les réserves sur les masques doux, les XObject dupliqués et le CMJN
Rendre puis rogner rééchantillonne les pixels et jette les données originales, tandis que GetPageImageList énumère les ressources image stockées avec leurs propriétés et leurs flux intacts

Polices : une surface d'audit, pas une fonction d'export

L'API de polices répond à des questions sur les polices. Elle ne vous remet pas les fichiers de polices eux-mêmes, et cette distinction façonne tout ce que vous pouvez bâtir dessus. Une fois que FindFonts a balayé le document, l'énumération parcourt les polices par identifiant, et les appels de propriétés rapportent sur la police actuellement sélectionnée :

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // les index de police commencent à 1, pas à 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

Surveillez les bornes de boucle. Les index de police vont de 1 à FontCount, tandis que les index de blocs de texte et de listes d'images vus quelques paragraphes plus haut partent de zéro. Transportez une convention dans l'autre et vous obtenez un décalage de un qui saute la première police ou déborde à la fin, et il passera un test superficiel parce que la plupart des documents ont plusieurs polices et que la mauvaise reste plausible. Soyez clair sur le périmètre également. Cette API n'a pas d'export de polices au niveau des octets. Aucun appel ne renvoie le programme de police incorporé sous forme de fichier TTF ou OTF, et l'énumération accompagnée de l'inspection des métadonnées est tout le modèle prévu. Ce modèle couvre pourtant ce que le travail de production demande réellement aux polices : détection de sous-ensembles par motif de nom, audits d'incorporation avant une conversion archivistique (une police non incorporée est un obstacle rédhibitoire au PDF/A, comme le développe le preflight PDF/A et PDF/UA en Delphi) et diagnostics d'encodage quand la confiance d'extraction chute. Il y a aussi une raison de licence à cette frontière. Un programme de police sous-ensemblé est un matériel sous licence et, privé de la plupart de ses glyphes, inutilisable comme police installable de toute façon. Le traiter comme une métadonnée d'audit plutôt que comme un actif extractible est la position que vous pouvez défendre

Ce dernier appel gagne son salaire au tri. Passez GetFontEncoding sur chaque police, lisez-le à côté de l'indicateur de sous-ensemble, et vous pouvez prédire la qualité d'extraction avant d'extraire un seul caractère. Une page dont toutes les polices sont sous-ensemblées avec des encodages non standard est un candidat OCR à la seule inspection, ce qui permet à un pipeline de lots de l'aiguiller correctement sans gaspiller d'abord une passe d'extraction ratée dessus

Extraire à grande échelle sans charger les documents

Dans un pipeline de lots, charger un document entier juste pour lire une page est une entrée-sortie gaspillée, et cela s'accumule vite sur un corpus. Les variantes en un seul appel, ExtractFilePageText et ExtractFilePageTextBlocks, prennent directement un nom de fichier, un mot de passe et un numéro de page, et sautent le chargement complet. Pour les fichiers de l'ordre du gigaoctet, il existe un rapport encore plus court. Le chemin d'accès direct ouvre un fichier par lectures de xref en flux, si bien que DAOpenFileReadOnly suivi de DAExtractPageText ne touche que les objets dont cette page a réellement besoin. Il s'accompagne d'un changement de convention à retenir : les fonctions DA adressent les pages par PageRef, un handle de référence objet obtenu de DAFindPage, jamais par numéro de page brut. Passez le numéro là où va le handle et l'appel travaille sur le mauvais objet sans lever d'erreur, la pire sorte de faute à déboguer. Le reste de la boîte à outils d'accès direct est exposé dans la fusion, la découpe et l'accès direct des gros PDF

S'il y a une seule habitude qui sépare le code d'extraction qui survit à un vrai corpus de celui qui boite, c'est de traiter la page comme une entrée non fiable plutôt que comme une source de données propre. Un texte en désaccord avec ce que rend la visionneuse est presque toujours un problème d'encodage, une ligature réduite à un glyphe ou une police sous-ensemblée privée de ses entrées ToUnicode, et le remède est de mesurer la confiance et de détourner les mauvaises pages vers l'OCR, pas de se battre contre les octets. L'API de polices ne produira jamais de TTF ni d'OTF, à dessein, alors bâtissez les flux de polices autour de questions d'audit. Et l'état d'extraction persistant, le rectangle de zone surtout, est un réglage dont vous êtes responsable pour toute la vie d'un handle de document, pas un paramètre que vous oubliez après un appel. Acquérez ces trois réflexes et le reste de l'API se tient bien

Les versions d'évaluation, les projets de démonstration et la référence complète de l'API d'extraction sont sur la page produit de losLab PDF Library for Delphi