Artículo técnico

OCR con Tesseract a PDF con texto buscable en Delphi

HotPDF convierte páginas PDF escaneadas en un PDF con texto buscable con Tesseract a través de HPDFCreateTesseractOCREngine, una factoría que envuelve un ejecutable Tesseract instalado localmente como un IHPDFOCREngine. Ese motor se lo pasa a ApplyLoadedOCRTextLayer, que renderiza cada página, ejecuta Tesseract una vez por página, parsea su salida TSV a nivel de palabra, y compromete una capa de texto Unicode invisible para todas las páginas pedidas en una sola transacción, o para ninguna

Pipeline OCR de HotPDF por página: renderizar la página al DPI configurado, guardar input.bmp en un directorio privado HotPDF-OCR, lanzar el proceso hijo Tesseract con tessedit_create_tsv, parsear el TSV de doce columnas, filtrar palabras por confidence, y comprometer la capa de texto invisible para todas las páginas pedidas o ninguna
El adaptador solo sustituye el reconocimiento: el render, el parseo, la validación y el commit todo-o-nada se quedan en el pipeline de capa de texto existente, así que el código aguas abajo nunca cambia

La razón de que exista este adaptador es el alcance. El motor OCR de plantillas integrado es estrecho a propósito: letras y dígitos ASCII impresos por máquina, nada más. Las facturas con nombres acentuados, los contratos en chino y los archivos multilingües necesitan un reconocedor de verdad con modelos de lenguaje entrenados, y Tesseract es el candidato obvio porque es un programa de línea de comandos que se puede aprovisionar junto a su aplicación. Llamar a un programa externo desde una librería de documentos suena a trivial. No lo es, y la mayor parte del código interesante del adaptador va de qué pasa cuando el programa se porta mal, se cuelga, se cancela o hereda cosas que jamás debería ver

¿Cómo maneja HotPDF Tesseract desde una aplicación Delphi?

HotPDF ejecuta Tesseract como un proceso hijo oculto por página, alimentándolo con un bitmap renderizado y leyendo de vuelta un archivo TSV, y expone el resultado por la misma costura IHPDFOCREngine que usa el motor integrado. Nada aguas abajo cambia: el mapeo de coordenadas, el manejo de rotación, la validación Unicode, el filtrado por confidence y el commit atómico son el pipeline de capa de texto que usted ya tiene. La factoría vive en la unit HPDFTesseractRecognition y valida con ganas: el ejecutable debe existir, el directorio tessdata debe existir, el timeout debe estar entre 1 y 3.600.000 milisegundos, y el identificador de idioma solo puede contener letras ASCII, dígitos, _ y +. Esa última comprobación importa porque la cadena de idioma acaba en una línea de comandos, y eng+chi_sim es un valor Tesseract legítimo mientras que cualquier cosa con comillas o espacios no lo es

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // lanza EArgumentException por un ejecutable ausente, tessdata ausente,
  // un identificador de idioma malo, o un timeout fuera de 1..3600000 ms
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // varios modelos unidos con '+'
    120000);            // límite por página, por defecto 60000
  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;
    // una lista de páginas vacía significa todas; las páginas con texto se saltan por defecto
    if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words accepted, ', Info.DroppedWordCount, ' dropped');
      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;

Para cada página, Recognize crea un directorio privado bajo la ruta temp llamado HotPDF-OCR-{GUID}, guarda el bitmap renderizado como input.bmp, y lanza tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, con cada argumento de ruta entrecomillado según las reglas de escape de línea de comandos de Windows para barras invertidas y comillas incrustadas. El valor de --dpi es el DPI de render de THPDFOCRTextLayerOptions.DPI, así que Tesseract nunca tiene que adivinar la resolución desde los metadatos de la imagen, y --psm 3 pide segmentación de página completamente automática. El motor se reporta como Tesseract (local CLI), que es lo que aterriza en Info.EngineName. Tesseract y sus modelos de lenguaje no vienen con HotPDF; instalarlos es trabajo de la aplicación

¿Por qué es tan estricto el parser TSV?

El parser TSV de HotPDF falla la página entera ante cualquier fila malformada, porque una lista de palabras parcialmente parseada produce una capa de texto que discrepa silenciosamente de la imagen. La salida TSV de Tesseract tiene una cabecera fija de doce columnas, de level a text, y HotPDF compara la primera línea contra esa cabecera exacta tras quitar un byte order mark opcional. Cada fila siguiente debe partirse en exactamente doce campos, y el split se para tras la undécima tabulación para que una tabulación dentro del texto reconocido se quede como parte de la palabra en vez de crear una décimo tercera columna. Solo las filas de nivel 5 son palabras; los niveles 1 a 4 describen páginas, bloques, párrafos y líneas, y se saltan. Las filas de nivel 5 cuyo texto está vacío o es solo espacios en blanco también se saltan, porque una palabra en blanco tiene caja pero nada que localizar o buscar. Todo lo demás se comprueba a fondo: geometría entera, una confidence parseada con formato invariante en-US para que un locale alemán no lea 93.5 como basura, una caja que cae totalmente dentro del bitmap, y una confidence entre 0 y 100. Un solo fallo lanza excepción, el motor devuelve False, y el array de palabras se limpia. Los tests de regresión incluyen exactamente ese caso: una palabra válida seguida de una fila rota debe dar cero palabras, no una

Seis compuertas que toda fila TSV de Tesseract pasa en HotPDF: cabecera exacta de doce columnas, exactamente doce campos, solo nivel 5, texto no en blanco, una caja dentro del bitmap, y confidence de 0 a 100 parseada de forma invariante, donde una fila rota tumba la página entera hasta cero palabras
Una lista de palabras parcialmente parseada discreparía en silencio de la imagen, así que el parser rechaza la página entera a la primera fila malformada en vez de conservar las palabras que ya leyó
// condensado del bucle de nivel 5 en HPDFLocalTSVRecognition
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // filas de page/block/paragraph/line
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // las palabras de espacios en blanco no tienen posición
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
  not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
  not TryStrToFloat(Fields[10], Confidence, Settings) then
  raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
  (Int64(X) + W > Request.Bitmap.Width) or
  (Int64(Y) + H > Request.Bitmap.Height) or
  not ((Confidence >= 0) and (Confidence <= 100)) then
  raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100;  // el pipeline espera 0..1

Esa última línea interactúa con un default que quizá no espera. La confidence de Tesseract va de 0 a 100, el pipeline trabaja de 0 a 1, y THPDFOCRTextLayerOptions.MinimumConfidence hace default a 0.5, así que cualquier palabra de Tesseract por debajo de 50 se cuenta en Info.DroppedWordCount y nunca llega a la página. En un escaneo limpio a 300 DPI es un suelo razonable. En un fax ruidoso puede tirarse una parte sorprendente de la página, y lo correcto es mirar el conteo de descartadas antes de bajar el umbral, porque las palabras de baja confidence son justo las que más probablemente están mal

¿Qué hereda el proceso hijo de Tesseract?

El proceso hijo de Tesseract hereda exactamente dos handles de HotPDF: un handle NUL para la entrada y salida estándar, y un handle de archivo para el error estándar. Esa precisión es el punto. CreateProcess con bInheritHandles = True es como se le pasan los handles estándar a un hijo, pero por su solo pasa todos los handles heredables del proceso anfitrión, incluidos archivos, pipes y eventos abiertos por código no relacionado en su aplicación. El hijo mantiene entonces esos objetos vivos hasta que sale, así que un archivo se queda bloqueado o un pipe nunca ve su fin mientras Tesseract machaca una página. HotPDF cierra ese hueco con un registro de arranque extendido: STARTUPINFOEX, una lista de atributos que lleva PROC_THREAD_ATTRIBUTE_HANDLE_LIST, y el flag de creación EXTENDED_STARTUPINFO_PRESENT. Con la lista de handles en su sitio, bInheritHandles todavía tiene que ser True, pero solo los handles listados cruzan la frontera. La misma forma de pensar en contención impulsa aislar codecs de imágenes PDF en procesos worker, donde el hijo es código no confiable; aquí el hijo es confiable, pero el anfitrión no es el único dueño de su propia tabla de handles

Herencia de handles del proceso hijo de Tesseract en HotPDF: un CreateProcess llano con bInheritHandles pasa al hijo todo handle heredable de archivo, pipe y evento, mientras que STARTUPINFOEX con PROC_THREAD_ATTRIBUTE_HANDLE_LIST limita el conjunto a un handle NUL para stdin y stdout más el handle de archivo de stderr
Sin la lista de atributos el hijo mantiene objetos ajenos vivos hasta que sale, bloqueando archivos y matando de hambre a los pipes; con ella, solo los dos handles listados cruzan la frontera
// constantes mostradas por nombre; la fuente pasa sus valores numéricos
// ambos handles se crean con bInheritHandle = True
InheritedHandles[0] := NullHandle;    // stdin y stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt en el directorio privado
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
  PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
  @InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
  True,                                        // exigido por la lista de handles
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

¿Por qué una ejecución OCR cancelada puede parecer un fallo del motor?

Una ejecución OCR cancelada parece un fallo del motor porque IHPDFOCREngine.Recognize devuelve un único Boolean, y False significa a la vez «Tesseract falló» y «el usuario pulsó Cancelar». El adaptador sondea el token de cancelación y el timeout cada 25 milisegundos mientras el hijo corre, y cuando el token salta lanza una excepción dentro de Recognize, se la caza él mismo, limpia, y devuelve False con un diagnóstico. Si el pipeline tratara eso como un error de motor, quien llama vería otlsEngineError para un trabajo que el usuario paró deliberadamente. ApplyLoadedOCRTextLayer comprueba por eso primero el token cada vez que Recognize devuelve False, y solo convierte el resultado en un fallo de motor si el token no estaba puesto. Ese orden preserva el contrato multipágina: reconocimiento, validación, contabilidad de budget y construcción de contenido corren para cada página pedida antes de que la transacción del grafo abra, así que una cancelación en la página 40 de 50 reporta otlsCancelled y deja el documento, incluidas las primeras 39 páginas, intacto. No hay un archivo parcialmente buscable que explicar después, y el resto del manejo de fallos sigue el mismo estilo acotado:

  • El timeout es por llamada a Recognize, medido desde su inicio, así que los 60.000 ms por defecto aplican a cada página y no al documento entero
  • Un hijo que sigue corriendo en un timeout o una cancelación se termina, se le espera hasta 5 segundos, y su directorio privado se borra en un bloque finally
  • output.tsv tiene un tope de 64 MiB y stderr.txt de 1 MiB, comprobado mientras el hijo corre y también después de salir
  • El conteo de palabras y las code units UTF-16 tienen tope por página según los budgets restantes de MaxWordsPerPage, MaxTotalWords y MaxTextCodeUnits, y pasarse hace fallar la ejecución en vez de truncar la lista de palabras
  • La salida estándar va a NUL porque Tesseract escribe output.tsv, mientras que el error estándar va a un archivo para que un código de salida distinto de cero se reporte con hasta 4.096 caracteres de la queja propia del motor, normalmente la forma más rápida de enterarse de que falta un archivo .traineddata

Cómo las palabras reconocidas se convierten en una capa de texto invisible

HotPDF escribe las palabras de Tesseract como texto invisible usando el modo de renderizado de texto 3, el modo sin relleno ni trazo definido en ISO 32000-1 §9.3.6, así que la página sigue mostrando la imagen escaneada mientras la búsqueda y la copia trabajan sobre las palabras reconocidas. El content stream abre BT con 3 Tr, y cada palabra recibe una matriz Tm en su línea base, un tamaño de fuente derivado de la altura de la caja en píxeles al DPI de render, y una escala horizontal Tz que estira la tirada de glifos hasta el ancho medido de la caja, que es la razón por la que un resaltado de búsqueda cae sobre la palabra en la imagen en vez de derivar por ella

El TSV de Tesseract tiene cajas pero no líneas base, así que el adaptador reporta cada palabra sin línea base y el pipeline estima esta a un quinto de la altura de la caja por encima del borde inferior. El texto en sí va por una fuente Type0 sin incrustar compartida con encoding Identity-H y un CMap ToUnicode generado, un CID por cada escalar Unicode distinto de toda la tirada, que es como el chino, el latín acentuado y los caracteres del plano suplementario sobreviven a copiar y buscar. Ese diseño tiene dos límites que conviene decir de entrada: una tirada puede llevar como máximo 65.535 escalares distintos, y la fuente sin incrustar no satisface el requisito de incrustación de fuentes de ISO 19005, así que la salida PDF/A necesita una fuente conforme incrustada aparte. Comprobar el resultado es simple y merece automatizarse: guarde, recargue, y corra el camino de texto corriente de documento cargado de extraer texto de un PDF cargado en Delphi; si las palabras vuelven en las páginas esperadas, la capa es real

RapidOCR y otros motores sobre el mismo protocolo TSV

HotPDF reutiliza el mismo runner de procesos y parser TSV para RapidOCR vía HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), que es la opción más útil para escaneos en chino simplificado. La línea de comandos es idéntica salvo que la ruta del script puente se inserta tras el ejecutable de Python, y el idioma queda fijo en chi_sim. HotPDF distribuye el puente como tools/OCR/rapidocr_tsv.py; espera los paquetes rapidocr y onnxruntime más tres modelos ONNX locales, desactiva las descargas automáticas de modelos, y escribe TSV con forma de Tesseract para que el lado Delphi no necesite un segundo parser. El nombre de motor reportado en Info.EngineName es RapidOCR (local ONNX). Esa forma sugiere la receta general: cualquier reconocedor que usted pueda envolver en un script pequeño que acepte la lista de argumentos estilo Tesseract y emita el TSV de doce columnas hereda gratis el aislamiento de handles, el timeout, la cancelación, los budgets de salida y el commit todo-o-nada. Los adaptadores son solo Windows, corren una página a la vez de forma síncrona, y no enderezan ni preprocesan la imagen más allá de lo que el renderer produce, así que la calidad de la imagen que entra sigue poniendo el techo de lo que sale

Los adaptadores Tesseract y RapidOCR, el escritor de capa de texto invisible, el renderer de páginas que los alimenta y la extracción de texto que verifica el resultado llegan todos en el mismo componente VCL nativo para Delphi y C++Builder. Si está añadiendo OCR a una aplicación de captura documental o de archivo, el componente PDF HotPDF para Delphi le da el pipeline con solo el motor OCR mismo por instalar