Article technique

Extraire du texte PDF en Delphi : espaces et sauts de ligne

HotPDF Delphi Component reconstruit les espaces de mots et les sauts de ligne dans THotPDF.ExtractLoadedPageText à partir de la géométrie des glyphes, pas à partir de caractères espace. Une espace entre quand l'écart après la largeur propre d'un glyphe dépasse 0.15 de la hauteur du texte, et une nouvelle ligne ne démarre que quand l'origine du texte franchit la direction d'écriture de plus de la moitié de la hauteur du texte. Depuis la v2.768.3, le texte de la page inclut aussi le texte peint via des Form XObjects et laisse dehors les glyphes hors de la zone visible recadrée. Le reste de cet article explique pourquoi chaque règle a la forme qu'elle a, parce que chacune a remplacé une règle plus simple qui produisait une sortie plausible mais fausse sur de vrais documents

Les symptômes sont familiers à quiconque a nourri un index de recherche avec du texte PDF. Une couverture s'extrait en PDFReferenceManualNovember4,1998, un formulaire fiscal se découpe en 156 lignes, un filigrane diagonal arrive à raison d'un caractère par ligne, et une épreuve rognée commence par la slug line d'imprimeur qu'aucun lecteur ne montre. Aucun de ces fichiers n'est cassé. Chacun emploie une façon parfaitement légale de placer du texte qu'un extracteur naïf lit de travers

Pourquoi le texte PDF extrait perd-il ses espaces de mots ?

Le texte extrait perd ses espaces de mots parce qu'un PDF n'est jamais tenu d'en contenir. Un producteur peut séparer les mots en affichant un caractère espace, mais il peut tout aussi bien déplacer le style avec un nombre dans un tableau TJ (ISO 32000-1 §9.4.3) ou avec un Td frais (§9.4.2), et la sortie TeX, beaucoup de fichiers Distiller et la plupart des mises en page justifiées font exactement ça. Avant la v2.766.76, HPDFAssemblePageText ne regardait que le mouvement vertical, donc une rupture de mot faite par positionnement disparaissait purement et simplement. L'assembleur mesure maintenant, le long de la direction d'écriture du glyphe précédent, la distance de la fin de la largeur propre de ce glyphe à l'origine du glyphe courant, et insère une espace quand la distance dépasse 0.15 de la hauteur de boîte du glyphe courant, mesurée de l'ascendante à la descendante en espace utilisateur. Aucune espace n'est ajoutée quand l'un des deux côtés est déjà blanc, ni entre deux caractères CJK, parce que la justification écarte les idéogrammes sans que cet écart signifie une frontière de mot. Les enregistrements de glyphes exposent la même géométrie, donc vous pouvez reproduire la décision quand un fichier particulier vous laisse perplexe

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // hauteur ascendante-descendante de la boîte du glyphe, en espace utilisateur
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // texte horizontal : écart depuis la fin de la largeur propre du glyphe précédent
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

Pourquoi mesurer depuis la largeur propre du glyphe plutôt que depuis la position du style ?

HotPDF mesure les écarts de mots depuis GlyphEndX / GlyphEndY parce que la position du style après un glyphe contient déjà de l'espacement qui n'est pas un écart. ISO 32000-1 §9.4.4 définit le déplacement horizontal comme la largeur du glyphe fois la taille de police, plus l'espacement de caractères Tc, plus l'espacement de mots Tw, le tout mis à l'échelle par Tz. BaselineEndX / BaselineEndY portent ce déplacement complet, tandis que GlyphEndX / GlyphEndY ne portent que l'avance de police et Tz. La différence compte pour les producteurs qui resserrent l'interlettrage avec un Tc négatif puis rendent l'espace via un ajustement TJ après chaque glyphe : mesuré depuis la position du style, le rendu ressemble à un écart, et le terme chinois “95后” s'extrayait en “9 5 后”. Le seuil est lié à la hauteur de boîte du glyphe plutôt qu'à la taille Tf pour une raison similaire. Les exports Word écrivent souvent 1 Tf et portent la vraie taille dans un Tm mis à l'échelle, donc Tfs dit 1 alors que le texte fait 10 points, et une règle calée sur Tfs traiterait différemment les deux graphies d'une même page

La règle d'espace de mots de HotPDF pour ExtractLoadedPageText en Delphi : une espace n'est insérée que quand la distance du GlyphEndX du glyphe précédent au BaselineStartX du glyphe suivant dépasse 0.15 de la hauteur de boîte ascendante-descendante, car la position du style dans BaselineEndX contient déjà Tc, Tw et Tz et transforme les rendus d'interlettrage justifié en faux écarts comme 9 5 后
La géométrie, pas les caractères espace, décide où les mots se coupent — les enregistrements de glyphes exposent les mêmes mesures, donc vous pouvez rejouer la décision pour n'importe quel fichier déroutant

La règle a des bords honnêtes. Un titre composé avec un interlettrage très lâche, où Tc seul ouvre plus de 0.15 de la hauteur du texte entre les lettres, s'extrait avec une espace entre chaque lettre, ce qui ressemble à la page mais n'est probablement pas ce que vous vouliez indexer. Des morceaux dessinés hors ordre sur une même ligne de base produisent un écart négatif et se collent sans espace. Ni l'un ni l'autre cas n'est courant dans du texte courant, et sur un corpus de test le changement a fait monter les correspondances de mots contre un extracteur de référence sur 28 pages sans en baisser aucune

Quand HotPDF démarre-t-il une nouvelle ligne dans le texte extrait ?

Depuis la v2.766.79, une nouvelle ligne démarre quand le mouvement de l'origine du glyphe précédent à celle du glyphe courant, projeté sur la normale de la direction d'écriture précédente, dépasse la moitié de la plus grande hauteur de boîte des deux glyphes. L'ancienne règle comparait le mouvement Y brut à la moitié de Tfs, ce qui échouait dans deux sens. Avec 1 Tf et un Tm mis à l'échelle, le seuil rétrécissait à une demi-unité, donc un exposant monté par une élévation de texte de 0.4 ou un banal frémissement de ligne de base coupait la ligne. La règle ignorait aussi X entièrement, donc du texte sous un Tm tourné descendait la page à chaque glyphe et sortait un glyphe par ligne. Projeter sur la normale de la direction fait se comporter les suites tournées comme des horizontales, et prendre la plus grande des deux hauteurs garde un grand mot d'exemple et sa petite légende sur une ligne quand ils partagent une ligne de base. Sur le formulaire fiscal cité plus haut, le compte de lignes est tombé de 156 à 97. Le texte vertical en mode d'écriture 1 (§9.7.4.3) suit une voie séparée : ces glyphes sont groupés en colonnes, lus de droite à gauche et de haut en bas, avec un saut de ligne à chaque changement de colonne

Comment ExtractLoadedPageText de HotPDF décide des sauts de ligne en Delphi : le mouvement entre origines de glyphes est projeté sur la normale de la direction d'écriture et comparé à la moitié de la plus grande hauteur de boîte, si bien qu'un exposant monté par une petite élévation de texte sous une police 1 Tf et du texte descendant la page sous un Tm tourné ne se coupent plus en un glyphe par ligne
La projection fait se comporter les suites tournées comme des horizontales, et prendre la plus grande des deux hauteurs de boîte garde un grand mot d'exemple et sa petite légende sur une ligne

Quel texte ExtractLoadedPageText inclut-il ou laisse-t-il dehors ?

ExtractLoadedPageText renvoie le texte qu'un lecteur montre. Depuis la v2.766.80, il travaille à partir des seuls glyphes visibles, en jetant chaque glyphe dont le centre de boîte tombe hors de GetLoadedPageVisibleBox, c'est-à-dire le CropBox clippé à la MediaBox (§14.11.2). Ça retire les slug lines et autres marques d'imprimeur composées en texte hors de la zone de coupe. ExtractLoadedPageGlyphs continue délibérément de renvoyer chaque glyphe du flux de contenu de la page, donc vous pouvez encore trouver ce matériau quand vous en avez besoin. Le filtre est un test de boîte, pas un test de visibilité : le texte caché par un chemin de clipping, peint en blanc ou couvert par une image est quand même extrait

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // chaque glyphe du flux de contenu de la page, slug line comprise
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // seulement ce que la page montre, avec le texte des Form XObjects inséré
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Le texte peint via des Form XObjects fait partie du texte de page depuis la v2.768.3. Les en-têtes, tampons et filigranes logent très souvent dans des formulaires, et certains documents de normes perdaient 30 à 35 pour cent de leurs caractères avant le changement. THotPDF.InterpretContentWithForms enregistre chaque Do avec la CTM en vigueur, interprète le formulaire à son /Matrix fois cette CTM (§8.10.1), et insère les glyphes du formulaire à la position du Do, en récurse dans les formulaires imbriqués. Un formulaire sans /Resources propre emprunte celles du flux qui le peint, comme le permet §7.8.3. Les glyphes de formulaire portent TokenIndex = -1, et ExtractLoadedPageGlyphs ne renvoie toujours que les glyphes du flux de page, parce que recherche, remplacement et rédaction réécrivent les changements via TokenIndex et éditeraient les mauvais octets si un glyphe de formulaire se glissait dedans. Deux simplifications valent d'être connues : le texte de formulaire n'est pas clippé au /BBox du formulaire, et la récursion s'arrête à 12 niveaux plutôt que par détection de cycles, donc un formulaire mal formé qui se peint lui-même répète son texte jusqu'à atteindre ce plafond

Quels glyphes HotPDF inclut en extrayant le texte de pages PDF en Delphi : ExtractLoadedPageText ne garde que les glyphes dont le centre de boîte tombe dans GetLoadedPageVisibleBox, le CropBox clippé à la MediaBox, si bien que les slug lines d'imprimeur disparaissent, tandis que InterpretContentWithForms insère les glyphes des Form XObjects à chaque position Do avec TokenIndex posé à -1 et l'API au niveau glyphe renvoie toujours tout
Un test de boîte sur le centre du glyphe n'est pas un test de visibilité — texte blanc, texte clippé et texte couvert sortent quand même, et le texte de formulaires compte depuis la v2.768.3

Pourquoi le texte après un opérateur Q se décodait-il en charabia ?

Le texte après Q pouvait se décoder de travers avant la v2.766.73 parce que l'extracteur ne sauvegardait que la CTM sur q. Les paramètres d'état de texte, à savoir police, taille, Tc, Tw, Tz, TL, mode de rendu et élévation, appartiennent à l'état graphique (§9.3.1), donc Q doit les restaurer avec tout le reste de la pile (§8.4.2). Un rapport du secteur sélectionnait une police deux octets Identity-H à l'intérieur de q … Q puis montrait du texte WinAnsi un octet sans Tf propre. L'extracteur gardait la police interne, lisait les filets et le mot “Adobe” de la table des matières comme des codes deux octets, et perdait 15% des caractères de la page. La pile q/Q de l'interpréteur porte maintenant l'état de texte complet. Les règles d'extraction décrites ici s'appliquent à chaque page, donc un document entier peut partir vers un fichier en un seul appel

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // plage vide = toutes les pages ; saut de page entre pages ; BOM UTF-8
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Quelle API de texte HotPDF employer ?

ExtractLoadedPageText reste dans l'ordre du flux de contenu, qui est le défaut qui convient pour la recherche et l'indexation ; la chaîne de décodage en dessous est couverte dans extraire du texte de PDF chargés avec HotPDF. Pour les documents étiquetés dont l'ordre de composition compte, l'extraction de texte dans l'ordre de structure parcourt l'arbre de structure au lieu de deviner d'après la géométrie, et pour les données coincées dans des tableaux, l'extraction de tableaux typée à travers les sauts de page renvoie des cellules plutôt que des lignes. La référence API complète et un téléchargement d'essai sont sur la page produit HotPDF Delphi PDF Component