Extraire le texte d'une page est la moitié facile du problème. Dès qu'un utilisateur tape un mot dans une zone de recherche et s'attend à ce que le lecteur saute dessus et l'encadre d'un rectangle jaune, il vous faut quelque chose qu'une simple chaîne de texte ne peut pas fournir : la page où se trouve chaque correspondance, et le rectangle qu'elle occupe dans les coordonnées PDF. Une chaîne concaténée à partir d'une page a perdu cette géométrie. Vous pouvez trouver la sous-chaîne, mais vous ne pouvez pas la pointer du doigt
PDFlibPas est une bibliothèque PDF native en Object Pascal pour Delphi et C++Builder, et depuis la version v3.78.0 elle répond précisément à cette question. Trois API de requête s'appuient sur l'extracteur de blocs de texte existant : SearchText parcourt une plage de pages et renvoie chaque occurrence avec sa page et son rectangle aligné sur les axes, EnumPageElements liste tout ce qui se trouve sur une page, blocs de texte et images intégrées compris, et GetTextInAreaEx renvoie le rectangle de chaque bloc à l'intérieur d'une région au lieu de les aplatir en liste de chaînes. Aucune ne touche au chemin d'écriture ; ce sont de simples ajouts côté lecture au-dessus de la mécanique déjà présente dans la bibliothèque
Pourquoi la géométrie vit dans la liste des blocs de texte, et pas dans l'entonnoir
Le réflexe naturel consiste à réutiliser ce que GetPageText exécute en interne. Ce chemin passe par un « entonnoir » d'extraction temporaire qui produit la chaîne de la page puis se détruit avant le retour de l'appel. Au moment où vous récupérez le résultat, les coordonnées de chaque bloc ont disparu. Elles n'ont jamais été à vous
Les coordonnées survivent bien dans une autre structure. ExtractPageTextBlocks(3) renvoie un handle de liste de blocs de texte dont chaque élément porte un quad de délimitation à huit doubles, un nom de police, une taille de police et le texte du bloc. Ce handle est le seul endroit où la géométrie est conservée après l'extraction, raison pour laquelle chacune des nouvelles API de requête s'appuie dessus plutôt que sur l'entonnoir. Réutiliser la liste de blocs permet à la recherche, à l'énumération et aux requêtes de région de partager une seule passe d'extraction et une seule définition de l'emplacement d'un bloc
La forme de SearchText découle donc de cette contrainte. Pour chaque page de la plage, elle extrait la liste de blocs, lit le texte de chaque bloc avec GetTextBlockText, le compare à la requête, et pour les blocs correspondants elle réduit le quad à un rectangle. L'occurrence renvoyée est un petit enregistrement :
type
TPDFlibSearchHit = record
Page: Integer; // 1-based page of the match
Left, Top, Right, Bottom: Double; // axis-aligned hit rectangle
MatchText: WideString; // the block text that contained the query
end;
Le tableau des bornes est entremêlé X/Y, pas composé de quatre coins
C'est le premier détail qui piège. GetTextBlockBound(ListID, Index, BoundIndex) prend un BoundIndex de 1 à 8, et ces huit valeurs ne sont pas « coin 1, coin 2, coin 3, coin 4 » avec deux champs groupés chacun comme on pourrait le croire. Elles sont X, Y, X, Y, X, Y, X, Y: les indices impairs sont des coordonnées X, les indices pairs sont des coordonnées Y, soit quatre points au total. Si vous les associez mal, votre rectangle devient absurde
La raison d'être d'un quad plutôt que d'un simple rectangle est la rotation. Un bloc de texte incliné possède un véritable polygone de délimitation à quatre points, et les huit doubles le décrivent fidèlement. Pour le cas d'usage surlignage et saut, on veut presque toujours une boîte droite à la place, donc la bibliothèque réduit le quad à un rectangle aligné sur les axes en parcourant les quatre points pour en extraire les X et Y minimaux et maximaux. Le texte pivoté se replie en la boîte droite qui l'englobe, ce dont a besoin une surcouche de surlignage :
var
Pdf: TPDFlib;
Hits: array[0..255] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('contract.pdf', '');
// Search pages 1 to 10, case-insensitive, substring match.
Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
[Hits[I].Page, Hits[I].Left, Hits[I].Top,
Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
finally
Pdf.Free;
end;
end;
Notez que le rectangle est exprimé en points de l'espace utilisateur PDF, avec l'origine en bas à gauche de la page, soit le même système de coordonnées que celui que vous passez aux appels de dessin et d'annotation. C'est intentionnel : le rectangle renvoyé par une occurrence de recherche est celui que vous pouvez transmettre directement à une annotation de surlignage ou à une commande « faire défiler ici » sans conversion
Sensibilité à la casse, mots entiers et différences du CJK
Le second paramètre est un ensemble TPDFlibSearchOptions constitué de soCaseSensitive et soWholeWord. L'ensemble vide [] est le cas courant : une recherche de sous-chaîne insensible à la casse. Ajoutez soCaseSensitive pour distinguer Indemnity et indemnity, ajoutez soWholeWord pour empêcher sign de correspondre à l'intérieur de signature, ou combinez les deux
La correspondance de mot entier exige de définir ce qu'est une frontière de mot, et ici la règle mérite d'être énoncée clairement parce qu'elle est volontairement centrée sur ASCII. Un caractère compte comme faisant partie d'un mot lorsqu'il s'agit d'une lettre ASCII, d'un chiffre ASCII ou d'un trait de soulignement : la [A-Za-z0-9_]classe familière des règles d'identifiants. Une correspondance n'est considérée comme mot entier que lorsque les caractères immédiatement avant et après elle ne sont pas mots (ou si l'occurrence se trouve à l'extrémité du bloc)
Les conséquences pour les scripts non latins sont à connaître avant de publier une zone de recherche multilingue. Comme les caractères han, les kana et les autres lettres non ASCII sortent de cette classe, toute frontière à côté d'eux est lue comme une limite non-mot. En pratique, cela signifie qu'une recherche de mot entier sur du texte CJK se comporte comme si chaque position était une frontière de mot valide, et le drapeau se ramène donc à une recherche de sous-chaîne dans ce cas. C'est une limite documentée, pas un bug, et cela correspond au comportement du modèle dont cette fonctionnalité a été tirée. Si votre corpus est surtout du CJK, le mode mot entier ne vous donnera pas la segmentation qu'apporterait un tokeniseur dédié ; prévoyez-le plutôt que de vous y fier
Une note d'implémentation qui explique une classe de défaillances subtiles ailleurs : la comparaison insensible à la casse utilise UpperCase sur le WideString, et non AnsiUpperCase. La variante Ansi renvoie un AnsiString, qui ne correspondrait pas au WideString que le reste du chemin utilise, et mélanger les deux produit des incompatibilités de types et, pire, une conversion approximative pour les caractères hors de la page de code active. Unicode entre, Unicode sort, jusqu'au bout
Un seul analyseur de plages de pages pour toute la bibliothèque
Le troisième paramètre est une chaîne de plage de pages telle que "1,3,5-9". Il n'y a rien de spécial dans son analyse : le même PLParsePageRangeList qui alimente PrintPages et les routines de copie de pages s'en charge aussi ici, donc une plage qui s'imprime correctement se recherche correctement. Une chaîne de plage vide est le marqueur de « toutes les pages », auquel cas SearchText construit elle-même la liste complète
La portée compte pour le coût. Rechercher un extrait de dix pages dans un document de mille pages n'extrait des blocs que pour dix pages, pas pour mille, parce que la boucle ne sélectionne et n'extrait que les pages nommées dans la plage. Si vous savez déjà qu'une clause se trouve en annexe, dites-le dans la plage et sautez le reste du fichier
En interne, la recherche et l'énumération changent toutes deux la page sélectionnée au fil de l'itération, donc chacune sauvegarde la page sélectionnée par l'appelant à l'entrée et la restaure dans un bloc finally. Appelez SearchText au milieu de la construction d'une page et votre sélection revient exactement là où vous l'avez laissée lorsque l'appel se termine. Ce contrat de sauvegarde et de restauration est le genre de chose que l'on ne remarque qu'à son absence, ce qui explique précisément pourquoi il existe
Énumérer une page entière : texte et images dans une seule liste
La recherche répond à « où se trouve ce mot ». L'autre moitié de l'introspection est « qu'y a-t-il sur cette page, au juste », et c'est EnumPageElements. Elle renvoie une liste unifiée où chaque élément est soit un bloc de texte, soit une image intégrée, distingué par un champ Kind:
type
TPDFlibPageElementKind = (ekText, ekImage);
TPDFlibPageElement = record
Kind: TPDFlibPageElementKind;
Page: Integer;
Left, Top, Right, Bottom: Double;
Text: WideString; // ekText
FontName: WideString; // ekText
FontSize: Double; // ekText
ImageID: Integer; // ekImage; usable with SelectImage / GetImageID
end;
Les éléments textuels proviennent de la même passe ExtractPageTextBlocks, donc chacun arrive déjà avec son rectangle, son nom de police et sa taille remplis. Les éléments image proviennent de la liste des images intégrées de la page via FindImages et GetImageID; le ImageID qu'ils transportent est le handle que vous transmettez à SelectImage pour inspecter l'image plus avant. Les deux types aboutissent dans un seul tableau, de sorte qu'un seul parcours de la page voit tout ce qui s'y trouve
var
Pdf: TPDFlib;
Elems: array[0..511] of TPDFlibPageElement;
Total, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('report.pdf', '');
Total := Pdf.EnumPageElements(1, Elems);
for I := 0 to Total - 1 do
if I <= High(Elems) then
if Elems[I].Kind = ekText then
WriteLn(Format('text %s/%.1f "%s"',
[Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
else
WriteLn(Format('image id=%d', [Elems[I].ImageID]));
finally
Pdf.Free;
end;
end;
Il existe ici une convention de comptage, conforme au reste de la bibliothèque, qu'il faut respecter sous peine de lire de la mémoire non initialisée. La valeur de retour est le nombre total d'éléments, qui peut être supérieur à la taille du tableau que vous avez passé. La fonction ne remplit que le nombre d'emplacements disponible et continue de compter les autres, exactement comme pour l'énumération des signatures. La garde est donc toujours la même : limitez votre boucle au plus petit des deux, le nombre renvoyé et High(array), et n'itérez jamais aveuglément jusqu'au compteur. Les exemples ci-dessus montrent le test I <= High(...) pour cette raison. Si la valeur de retour dépasse votre tampon, allouez un tableau plus grand et recommencez
Si vous avez déjà utilisé les appels de blocs de texte de plus bas niveau de la bibliothèque, voici la couche typée et consciente de la géométrie qui les prolonge ; l'extraction sous-jacente est la même que celle décrite dans l'extraction du texte, des images et des polices PDF avec PDFlibPas. Et lorsque l'objectif n'est pas « où se trouve ce texte » mais « comment ce document est-il structuré pour les technologies d'assistance », l'histoire parallèle côté lecture est l'arborescence de structure tagged-PDF, qui expose l'ordre de lecture logique plutôt que la disposition physique des blocs
Requêtes de région quand vous savez déjà où chercher
Parfois, vous n'avez pas de terme de recherche du tout ; vous avez un rectangle. Un modèle de formulaire place toujours le numéro de facture dans le coin supérieur droit, ou une mise en page numérisée réserve une bande fixe pour un tableau. GetTextInAreaEx sert dans ce cas. C'est l'équivalent tenant compte des bornes de GetTextInArea: là où l'ancien appel renvoie pour une région une liste plate de chaînes, le nouveau renvoie le rectangle de chaque bloc conservé avec son texte, de sorte que vous sachiez non seulement ce qui se trouve dans la zone, mais aussi à quel endroit chaque ligne se situe
var
Pdf: TPDFlib;
Hits: array[0..63] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf', '');
Pdf.SelectPage(1);
// Left, Top, Width, Height in PDF points on the selected page.
Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Hits[I].MatchText);
finally
Pdf.Free;
end;
end;
Deux points à garder distincts. GetTextInAreaEx s'applique à la page actuellement sélectionnée, donc appelez SelectPage d'abord ; contrairement à SearchText, il ne prend pas de plage. Et un bloc est conservé lorsqu'il intersecte avec le rectangle de requête, et pas seulement lorsqu'il est entièrement contenu, donc une ligne qui chevauche la frontière passe quand même. C'est généralement ce que vous voulez pour une boîte de sélection dessinée à la main, mais si vous avez besoin d'une inclusion stricte, vous pouvez filtrer vous-même les rectangles renvoyés, puisque vous les avez maintenant
Mettre cela en pratique
Le fil conducteur de ces trois appels est que la géométrie n'est plus quelque chose que vous reconstruisez après coup. Une occurrence connaît sa page et sa boîte. Un élément de page connaît son rectangle et, pour le texte, sa police. Une requête de région indique où tombe chaque ligne. C'est suffisant pour construire une vraie fonction de recherche et surlignage, un index de localisation au clic ou un extracteur sensible à la mise en page, sans redescendre sous l'API publique ni reconstruire à la main le pipeline d'extraction de texte
Ces API de requête sont livrées avec la PDFlibPas Delphi PDF Library, aux côtés de toute la couche d'extraction de blocs de texte sur laquelle elles s'appuient et du reste de la surface d'introspection côté lecture pour Delphi et C++Builder