Article technique

OCR Tesseract vers PDF consultable en Delphi avec HotPDF

Un tuyau qui ne signale jamais de fin de fichier est un bug étrange à traquer dans une fonctionnalité OCR. Un thread de votre application lit la sortie standard d'un programme auxiliaire et attend que ReadFile échoue avec ERROR_BROKEN_PIPE. Un autre thread lance tesseract.exe via CreateProcess avec bInheritHandles à True, parce que c'est ainsi qu'on remet à un processus enfant un fichier pour sa sortie d'erreurs. L'extrémité d'écriture du tuyau du helper était héritable, si bien que Tesseract détient désormais une copie, et le thread lecteur se bloque jusqu'à ce que Tesseract finisse sa page — ce qui, sur un scan à 300 DPI avec deux modèles de langue chargés, n'est pas immédiat

Un reconnaisseur externe apporte plus que le blocage : ce que l'enfant peut voir, que faire d'une sortie à moitié valide, et comment transformer des boîtes de pixels en texte PDF cherchable. Le moteur OCR intégré à appariement de gabarits couvre volontairement les lettres et chiffres ASCII imprimés par machine. Les noms accentués, les contrats chinois et les archives multilingues demandent des modèles de langue entraînés, et Tesseract est le candidat habituel parce que c'est un programme en ligne de commande que vous pouvez installer à côté de votre exécutable

Brancher Tesseract dans la couche de texte OCR de HotPDF

HotPDF enveloppe un exécutable Tesseract installé localement comme un IHPDFOCREngine via HPDFCreateTesseractOCREngine dans l'unité HPDFTesseractRecognition. Vous remettez ce moteur à THotPDF.ApplyLoadedOCRTextLayer, qui rend chaque page demandée, lance Tesseract une fois par page, filtre les mots et écrit une couche de texte Unicode invisible pour toutes les pages demandées en une seule transaction, ou pour aucune. La correspondance de coordonnées, la rotation, le filtrage par confiance et le commit sont le pipeline que le moteur intégré emploie déjà ; seule la reconnaissance change

Pipeline OCR HotPDF par page : rendre la page au DPI configuré, sauvegarder input.bmp dans un répertoire privé HotPDF-OCR, lancer le processus enfant Tesseract avec tessedit_create_tsv, analyser le TSV à douze colonnes, filtrer les mots par confiance, et engager la couche de texte invisible pour toutes les pages demandées ou aucune
L'adaptateur ne remplace que la reconnaissance : rendu, analyse, validation et engagement tout-ou-rien restent dans le pipeline de couche de texte existant, donc le code en aval ne change jamais

La factory lève EArgumentException avant que toute page ne soit touchée. L'exécutable et le répertoire tessdata doivent exister, le timeout doit se tenir entre 1 et 3 600 000 millisecondes, et la langue ne peut contenir que des lettres ASCII, des chiffres, _ et +, parce qu'elle finit sur une ligne de commande : eng+chi_sim est légitime, une valeur avec des guillemets ou des espaces est une injection qui attend de se produire. Une seconde surcharge prend un enregistrement THPDFTesseractOptions. Son Default donne --psm 3, --oem 3, 60 secondes par page et un plafond de 16 777 216 pixels, et il rejette tpsOSDOnly et tpsAutoOnly, les deux modes qui ne renvoient aucun mot

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

function MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken): Boolean;
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  TessOptions: THPDFTesseractOptions;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  TessOptions := THPDFTesseractOptions.Default; // psm 3, oem 3, 60 s, 16 777 216 px
  TessOptions.TimeoutMilliseconds := 120000;    // vaut pour chaque page
  // lève EArgumentException pour un exécutable ou un dossier tessdata manquant,
  // un identifiant de langue mauvais, ou des options hors de leurs plages
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',                              // modèles joints par '+'
    TessOptions);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default; // 300 DPI, MinimumConfidence 0.5
    Options.CancellationToken := Token;
    // une liste de pages vide signifie toutes les pages ; les pages portant déjà du texte sont sautées
    Result := Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info);
    if Result then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words kept, ', Info.DroppedWordCount, ' below the threshold');
      Doc.SaveLoadedDocument(TargetFile);
    end
    else
      case Info.Status of
        otlsCancelled:      Writeln('Cancelled, document unchanged');
        otlsEngineError:    Writeln('Engine: ', string(Info.Diagnostic));
        otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
      else
        Writeln(string(Info.Diagnostic));
      end;
  finally
    Doc.Free;
  end;
end;

Faites l'arithmétique des pixels une fois. Une page A4 à 300 DPI fait environ 2 481 par 3 508 pixels, à peu près 8,7 millions, et passe. Une page A3 à la même résolution fait environ 3 508 par 4 961, proche de 17,4 millions, si bien qu'elle échoue avec un diagnostic de budget de pixels et que l'exécution se termine en otlsEngineError. La surcharge qui ne prend qu'un timeout n'applique aucun plafond de pixels du tout. Pour un service qui accepte des fichiers de l'extérieur, gardez la surcharge à options et montez MaxPixels (jusqu'à 67 108 864) ou abaissez délibérément THPDFOCRTextLayerOptions.DPI quand de grands formats sont attendus

Par page, le moteur crée un répertoire privé HotPDF-OCR-{GUID} sous le chemin temporaire, enregistre le bitmap comme input.bmp et lance tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, en ajoutant --oem quand un mode moteur est réglé. Les chemins sont quotés avec les règles Windows pour les antislashs et les guillemets embarqués, et --dpi porte la résolution de rendu pour que Tesseract ne la devine jamais. Tesseract et ses fichiers .traineddata ne sont pas livrés ; les provisionner est le travail de l'application. Sur nos propres machines de test Linux, le reconnaisseur tourne depuis un bundle au niveau utilisateur du binaire, de sa fermeture de bibliothèques partagées et du dossier tessdata, parce que compter sur /usr/bin/tesseract ne fonctionne que là où quelqu'un a installé le paquet système

Pourquoi une ligne TSV cassée vide-t-elle toute la page ?

L'adaptateur Tesseract fait échouer la page entière à la première ligne malformée, parce qu'une liste de mots à moitié analysée produit une couche de texte en désaccord avec l'image, et rien dans le PDF enregistré ne dit quels mots ont disparu. La sortie doit se décoder en UTF-8 strict sans NUL embarqué. La première ligne doit égaler l'en-tête à douze colonnes de Tesseract, de level à text, une fois une BOM optionnelle retirée. Chaque ligne doit se découper en exactement douze champs, et la découpe s'arrête après la onzième tabulation, si bien qu'une tabulation dans le texte reconnu atterrit dans le champ texte au lieu de créer une treizième colonne, où le contrôle des caractères de contrôle ci-dessous la rejette alors

Seules les lignes de niveau 5 sont des mots ; les lignes de page, bloc, paragraphe et ligne sont sautées, tout comme les mots vides ou purement blancs. Un mot contenant un caractère de contrôle (sous U+0020, ou de U+007F à U+009F) fait échouer la page. Le reste exige une géométrie entière, une confiance analysée avec un formatage invariant pour qu'une application en locale allemande lise quand même 93.5, une boîte entièrement dans le bitmap, et une confiance de 0 à 100. Un mot valide suivi d'une ligne cassée donne zéro mot

Six portes que passe chaque rangée TSV de Tesseract dans HotPDF : en-tête exact de douze colonnes, exactement douze champs, niveau 5 seulement, texte non vide, une boîte à l'intérieur du bitmap, et une confiance de 0 à 100 analysée de façon invariante, où une seule rangée cassée fait échouer toute la page jusqu'à zéro mot
Une liste de mots partiellement analysée contredirait l'image en silence, donc l'analyseur refuse la page entière à la première rangée mal formée au lieu de garder les mots déjà lus

Une ligne étrange fait donc échouer toute l'exécution et laisse chaque page intacte. C'est le bon compromis. Une relance est bon marché ; un PDF consultable où la recherche d'un numéro de facture rate la seule page qui compte a tendance à refaire surface des mois plus tard, entre les mains de quelqu'un d'autre

La dernière étape rencontre aussi un défaut auquel on ne s'attend pas. Tesseract rapporte la confiance de 0 à 100, le pipeline divise par 100, et THPDFOCRTextLayerOptions.MinimumConfidence vaut 0,5 par défaut, si bien que tout mot en dessous de 50 atterrit dans Info.DroppedWordCount au lieu de la page. Sur un scan propre ce plancher est sensé. Sur un fax bruité il peut retirer une grande part du texte, alors comparez Info.ReceivedWordCount avec Info.AcceptedWordCount avant de l'abaisser

Relancer l'OCR sur un fichier qui porte déjà du texte

SkipPagesWithText vaut True par défaut, et cela surprend les gens la seconde fois. Supposez qu'un lot ait été traité avec eng et exige maintenant eng+deu : chaque page porte déjà du texte invisible, si bien que Info.SkippedPageCount égale le compte de pages. HotPDF marque le flux de contenu qu'il écrit comme sa propre couche OCR, et ReplaceExistingOCRTextLayer emploie cette marque pour reconnaître ces pages à nouveau, en abandonnant l'ancienne couche et en gardant le contenu de page d'origine

Options := THPDFOCRTextLayerOptions.Default;
Options.ReplaceExistingOCRTextLayer := True;  // remplace notre couche antérieure
Options.UseOptionalContentGroup := True;      // couche nommée 'OCR Text Layer'
Options.CancellationToken := Token;
if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
  Writeln(Info.ReplacedPageCount, ' replaced, ',
    Info.SkippedPageCount, ' skipped, ',
    Info.RecognizedPageCount, ' recognized')
else
  Writeln(string(Info.Diagnostic));

Un fichier OCRisé par un pilote de scanner ne porte pas une telle marque, si bien que ses pages comptent toujours comme des pages avec du texte. Désactiver SkipPagesWithText empilerait une seconde couche invisible sur la première, et chaque résultat de recherche apparaîtrait deux fois. Laissez ces fichiers tranquilles

Quels handles le processus Tesseract hérite-t-il ?

Le processus enfant Tesseract hérite d'exactement deux handles de HotPDF : un ouvert sur NUL pour l'entrée et la sortie standard, et un pour stderr.txt dans le répertoire privé. Le blocage de tuyau ci-dessus vient de CreateProcess lui-même : bInheritHandles = True est requis pour passer des handles standard, et à lui seul il passe chaque handle héritable de l'hôte, y compris tuyaux, fichiers et événements ouverts par du code sans rapport. HotPDF emploie STARTUPINFOEX avec une liste d'attributs portant PROC_THREAD_ATTRIBUTE_HANDLE_LIST et le drapeau EXTENDED_STARTUPINFO_PRESENT. bInheritHandles reste True, mais seuls les handles listés traversent

Héritage de handles du processus enfant Tesseract dans HotPDF : un CreateProcess simple avec bInheritHandles passe à l'enfant chaque handle de fichier, pipe et événement héritable, tandis que STARTUPINFOEX avec PROC_THREAD_ATTRIBUTE_HANDLE_LIST limite l'ensemble à un handle NUL pour stdin et stdout plus le handle de fichier stderr
Sans la liste d'attributs, l'enfant garde des objets sans rapport en vie jusqu'à sa sortie, verrouillant des fichiers et affamant des pipes ; avec elle, seuls les deux handles listés franchissent la frontière

Le même motif appartient à tout autre processus enfant que votre application lance, puisqu'un seul lanceur qui hérite de tout suffit à bloquer chaque lecteur de tuyau du processus :

// Motif pour vos propres appels CreateProcess (fragment : NullHandle, ErrorHandle,
// Exe, CommandLine, WorkDir et ProcessInfo sont déclarés ailleurs).
// Les RTL Delphi anciennes ne déclarent pas les fonctions de liste d'attributs ; ajoutez vos
// propres déclarations externes depuis kernel32 si le compilateur ne les trouve pas
// Vérifiez chaque résultat BOOL dans du code de production
type
  TStartupInfoExRec = record
    StartupInfo: TStartupInfo;
    AttributeList: Pointer;
  end;
const
  HANDLE_LIST_ATTRIBUTE = $00020002;   // PROC_THREAD_ATTRIBUTE_HANDLE_LIST
  EXTENDED_STARTUP_FLAG = $00080000;   // EXTENDED_STARTUPINFO_PRESENT
var
  Startup: TStartupInfoExRec;
  HandleList: array[0..1] of THandle;
  Size: NativeUInt;
begin
  FillChar(Startup, SizeOf(Startup), 0);
  HandleList[0] := NullHandle;         // créé avec bInheritHandle = True
  HandleList[1] := ErrorHandle;        // de même
  Size := 0;
  InitializeProcThreadAttributeList(nil, 1, 0, Size);
  GetMem(Startup.AttributeList, Size);
  InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, Size);
  UpdateProcThreadAttribute(Startup.AttributeList, 0, HANDLE_LIST_ATTRIBUTE,
    @HandleList[0], SizeOf(HandleList), nil, nil);
  Startup.StartupInfo.cb := SizeOf(Startup);
  // ... réglez dwFlags et hStdInput / hStdOutput / hStdError comme d'habitude ...
  CreateProcess(PChar(Exe), PChar(CommandLine), nil, nil,
    True,                              // toujours requis avec une liste de handles
    CREATE_NO_WINDOW or EXTENDED_STARTUP_FLAG,
    nil, PChar(WorkDir), Startup.StartupInfo, ProcessInfo);
  // après la sortie de l'enfant : DeleteProcThreadAttributeList, FreeMem, CloseHandle
end;

Les scripts ponts ajoutent un détail Windows de plus. Un lanceur Python peut démarrer un second processus d'interpréteur, et terminer le processus que vous avez créé laisse celui-là tourner. Quand on remet au moteur un chemin de script, comme c'est le cas de l'adaptateur RapidOCR ci-dessous, HotPDF démarre l'enfant suspendu, l'affecte à un job object avec JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE puis le relance, si bien que fermer le job termine tout l'arbre. La même idée de confinement anime l'isolation des codecs d'images PDF dans des processus workers, où l'enfant n'est pas fiable ; ici l'enfant est fiable, mais votre processus n'est pas le seul code à détenir des handles dedans

Annulation, timeouts et commit tout-ou-rien

IHPDFOCREngine.Recognize renvoie un seul Boolean, et False couvre à la fois « Tesseract a échoué » et « l'utilisateur a appuyé sur Annuler ». L'adaptateur interroge le token et son timeout toutes les 25 millisecondes pendant que Tesseract tourne, et sur annulation il nettoie et renvoie False. Pris au pied de la lettre, cela se lirait otlsEngineError, si bien que ApplyLoadedOCRTextLayer contrôle le token d'abord chaque fois que Recognize renvoie False. La reconnaissance et la construction du contenu se terminent pour chaque page demandée avant que la transaction du document ne s'ouvre, si bien qu'une annulation à la page 40 sur 50 rapporte otlsCancelled et laisse les 50 pages telles que chargées. Les autres limites suivent le même style :

  • Le timeout court depuis le début de chaque appel Recognize, si bien que le défaut de 60 000 ms est une limite par page
  • Un enfant encore en train de tourner au timeout ou à l'annulation est terminé et attendu jusqu'à 5 secondes ; le répertoire privé est retiré dans un bloc finally, avec suppression relancée jusqu'à une seconde parce que les fichiers de sortie peuvent rester verrouillés brièvement après la fin du processus
  • output.tsv est plafonné à 64 Mio et stderr.txt à 1 Mio, vérifiés pendant que l'enfant tourne et après sa sortie
  • Les mots et unités de code UTF-16 par page sont plafonnés par ce qui reste de MaxWordsPerPage, MaxTotalWords et MaxTextCodeUnits ; dépasser fait échouer l'exécution au lieu de tronquer la liste
  • Un code de sortie non nul revient avec jusqu'à 4 096 caractères de stderr.txt, le moyen le plus rapide d'apprendre qu'un fichier .traineddata manque

À quoi ressemble la couche de texte invisible dans la page

HotPDF écrit les mots en mode de rendu de texte 3 (ni remplissage ni contour, §9.3.6 de l'ISO 32000-1), si bien que le scan reste visible pendant que la recherche et la copie travaillent sur les mots. Le flux de contenu ouvre BT avec 3 Tr ; chaque mot reçoit une matrice Tm à sa ligne de base, une taille de fonte égale à la hauteur de boîte en pixels fois 72 sur la DPI de rendu, et un échelonnement Tz qui étire la série jusqu'à la largeur de boîte. Cet étirement garde une surbrillance de recherche sur le mot dans l'image

Le TSV a des boîtes mais pas de lignes de base, si bien que chaque ligne de base est placée à un cinquième de la hauteur de boîte au-dessus du bord bas. Entre mots voisins sur une ligne de gauche à droite dans les scripts à espaces comme le latin ou le cyrillique, une espace étirée comble l'écart, si bien que le texte copié garde ses coupures de mots ; le chinois n'en obtient pas. Les glyphes passent par une fonte Type0 partagée non embarquée à encodage Identity-H et un CMap ToUnicode généré, un CID par scalaire Unicode distinct, voilà comment le chinois, le latin accentué et les caractères hors plan de base survivent à la copie et à la recherche. Une série tient jusqu'à 65 535 scalaires distincts, et comme l'ISO 19005 exige des fontes embarquées, la sortie PDF/A demande une fonte conforme embarquée séparément. Pour contrôler un résultat, enregistrez, rechargez et lancez le chemin ordinaire de l'extraction de texte d'un PDF chargé en Delphi ; si les mots attendus reviennent sur les pages attendues, la couche est réelle

RapidOCR et la DLL native sur le même contrat

Pour les scans en chinois simplifié, HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) réutilise le même lanceur de processus et le même analyseur TSV. Le chemin du script pont suit l'exécutable Python et la langue est fixée à chi_sim. HotPDF livre le pont comme tools/OCR/rapidocr_tsv.py ; il exige les paquets rapidocr et onnxruntime, des modèles ONNX locaux et un fichier de fonte, refuse les téléchargements automatiques, et écrit un TSV à la Tesseract ; une surcharge THPDFRapidOCROptions règle les fichiers de modèles, les comptes de threads et un plafond de pixels. La factory générique derrière les deux adaptateurs, HPDFCreateLocalTSVOCREngine dans HPDFLocalTSVRecognition, accepte tout programme qui prend les arguments à la Tesseract et écrit le TSV à douze colonnes, et elle hérite de la liste de handles, du timeout, de l'annulation et des budgets

HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language) charge pour sa part l'API C de Tesseract en process, se déclare Tesseract (native DLL), vérifie d'entrée que chaque fichier .traineddata demandé existe, et projette annulation et timeout sur le propre moniteur de progression de Tesseract. Il économise un démarrage de processus et un aller-retour BMP par page ; l'exécutable garde les crashes et la croissance mémoire du reconnaisseur hors de votre espace d'adressage. Pour un outil de bureau travaillant sur les scans de l'utilisateur lui-même, la DLL convient. Pour un service qui avale des documents d'inconnus, le moteur en ligne de commande est le choix le plus sûr. Dans les deux cas la reconnaissance tourne sous Windows, une page à la fois, sur le bitmap rendu, alors redressez et nettoyez les scans difficiles avant qu'ils n'atteignent le moteur

Les adaptateurs OCR, l'écrivain de couche de texte, le moteur de rendu qui les alimente et l'extraction qui contrôle la sortie vivent tous dans un composant VCL natif unique pour Delphi et C++Builder. Si votre application de capture ou d'archivage a besoin d'une sortie PDF consultable, le composant HotPDF Delphi PDF couvre tout sauf le moteur OCR que vous installez vous-même