Artículo técnico

Tesseract como proceso externo: PDF buscable con HotPDF

Un pipe que jamás reporta end-of-file es un bug raro de perseguir en una feature de OCR. Un thread de su aplicación lee la salida estándar de un programa auxiliar y espera a que ReadFile falle con ERROR_BROKEN_PIPE. Otro thread arranca tesseract.exe vía CreateProcess con bInheritHandles en True, porque así es como se le entrega a un proceso hijo un archivo para su error estándar. El extremo de escritura del pipe del auxiliar era heredable, así que Tesseract ahora posee una copia, y el thread lector se cuelga hasta que Tesseract termina su página — que, en un escaneo de 300 DPI con dos modelos de idioma cargados, no es rápido

Un reconocedor externo trae más que el cuelgue: qué puede ver el hijo, qué hacer con salida medio válida, y cómo convertir cajas de píxeles en texto PDF buscable. El engine OCR de plantillas integrado cubre a propósito letras y dígitos ASCII impresos por máquina. Nombres acentuados, contratos en chino y archivos multilingües necesitan modelos de idioma entrenados, y Tesseract es el candidato usual porque es un programa de línea de comandos que se puede instalar junto a su ejecutable

Enchufar Tesseract a la capa de texto OCR de HotPDF

HotPDF envuelve un ejecutable Tesseract instalado localmente como un IHPDFOCREngine vía HPDFCreateTesseractOCREngine en la unit HPDFTesseractRecognition. Usted pasa ese engine a THotPDF.ApplyLoadedOCRTextLayer, que renderiza cada página pedida, corre Tesseract una vez por página, filtra las palabras y escribe una capa de texto Unicode invisible para todas las páginas pedidas en una transacción, o para ninguna. El mapeo de coordenadas, la rotación, el filtro de confianza y la confirmación son el pipeline que el engine integrado ya usa; solo cambia el reconocimiento

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 de Tesseract con tessedit_create_tsv, parsear el TSV de doce columnas, filtrar palabras por confianza, y confirmar la capa de texto invisible para todas las páginas solicitadas o para ninguna
El adaptador solo reemplaza el reconocimiento: renderizado, parseo, validación y la confirmación todo-o-nada quedan en el pipeline de capa de texto existente, así que el código aguas abajo jamás cambia

La factory lanza EArgumentException antes de tocar cualquier página. El ejecutable y el directorio tessdata deben existir, el timeout debe estar entre 1 y 3,600,000 milisegundos, y el idioma puede contener solo letras ASCII, dígitos, _ y +, porque termina en una línea de comandos: eng+chi_sim es legítimo, un valor con comillas o espacios es una inyección esperando ocurrir. Un segundo overload toma un record THPDFTesseractOptions. Su Default da --psm 3, --oem 3, 60 segundos por página y un tope de 16,777,216 píxeles, y rechaza tpsOSDOnly y tpsAutoOnly, los dos modos que no devuelven palabras

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;    // aplica a cada página
  // lanza EArgumentException por un ejecutable o carpeta tessdata faltante,
  // un identificador de idioma malo, u opciones fuera de sus rangos
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',                              // modelos unidos con '+'
    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;
    // una lista de páginas vacía significa todas; las páginas que ya tienen texto se saltan
    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;

Haga la aritmética de píxeles una vez. Una página A4 a 300 DPI es cerca de 2,481 por 3,508 píxeles, unos 8.7 millones, y cabe. Una página A3 a la misma resolución es cerca de 3,508 por 4,961, casi 17.4 millones, así que falla con un diagnóstico de presupuesto de píxeles y la corrida termina como otlsEngineError. El overload que toma solo un timeout no aplica tope de píxeles alguno. Para un servicio que acepta archivos de afuera, conserve el overload de opciones y suba MaxPixels (hasta 67,108,864) o baje THPDFOCRTextLayerOptions.DPI deliberadamente cuando se esperen formatos grandes

Por página, el engine crea un directorio privado HotPDF-OCR-{GUID} bajo la ruta temporal, guarda el bitmap como input.bmp y corre tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, agregando --oem cuando hay un modo de engine asignado. Las rutas se citan con las reglas de Windows para backslashes y comillas embebidas, y --dpi carga la resolución de render para que Tesseract jamás la adivine. Tesseract y sus archivos .traineddata no vienen incluidos; provisionarlos es trabajo de la aplicación. En nuestras propias máquinas de prueba con Linux el reconocedor corre desde un bundle a nivel de usuario del binario, su cierre de librerías compartidas y la carpeta tessdata, porque depender de /usr/bin/tesseract solo funciona donde alguien instaló el package del sistema

¿Por qué una fila TSV rota vacía la página completa?

El adaptador Tesseract hace fallar la página entera ante la primera fila malformada, porque una lista de palabras parcialmente parseada produce una capa de texto que discrepa con la imagen, y nada en el PDF guardado dice qué palabras faltaron. La salida debe decodificar como UTF-8 estricto sin NUL embebido. La primera línea debe ser igual al header de doce columnas de Tesseract, de level a text, una vez quitada una marca de orden de bytes opcional. Cada fila debe partirse en exactamente doce campos, y la partición se detiene tras el tabulador undécimo, así que un tab dentro del texto reconocido cae en el campo de texto en vez de crear una decimotercera columna, donde el chequeo de caracteres de control de abajo la rechazaría

Solo las filas de nivel 5 son palabras; las filas de página, bloque, párrafo y línea se saltan, igual que las palabras vacías o de puro espacio. Una palabra con un carácter de control (debajo de U+0020, o de U+007F a U+009F) hace fallar la página. El resto exige geometría entera, una confianza parseada con formato invariante para que una aplicación con locale alemán igual lea 93.5, una caja totalmente dentro del bitmap, y una confianza de 0 a 100. Una palabra válida seguida de una fila rota produce cero palabras

Seis compuertas que toda fila TSV de Tesseract pasa en HotPDF: header exacto de doce columnas, exactamente doce campos, solo nivel 5, texto no en blanco, una caja dentro del bitmap, y confianza de 0 a 100 parseada invariante, donde una fila rota hace fallar la página completa hasta cero palabras
Una lista de palabras parcialmente parseada discreparía en silencio con la imagen, así que el parser rechaza la página entera ante la primera fila malformada en lugar de quedarse con las palabras ya leídas

Una fila rara hace entonces fallar toda la corrida y deja todas las páginas intactas. Ese es el trade correcto. Una re-corrida es barata; un PDF buscable donde una búsqueda de número de factura no encuentra la única página que importa suele aparecer meses después, en manos de otra persona

El último paso además se topa con un default que quizá no espera. Tesseract reporta confianza de 0 a 100, el pipeline divide entre 100, y THPDFOCRTextLayerOptions.MinimumConfidence hace default a 0.5, así que toda palabra debajo de 50 cae en Info.DroppedWordCount en vez de en la página. En un escaneo limpio ese piso es sensato. En un fax ruidoso puede quitarse una buena parte del texto, así que compare Info.ReceivedWordCount con Info.AcceptedWordCount antes de bajarlo

Correr OCR otra vez sobre un archivo que ya tiene texto

SkipPagesWithText es True por default, y eso atrapa a la gente a la segunda vuelta. Suponga que un lote se procesó con eng y ahora necesita eng+deu: toda página ya carga texto invisible, así que Info.SkippedPageCount es igual al conteo de páginas. HotPDF marca el content stream que escribe como su propia capa OCR, y ReplaceExistingOCRTextLayer usa esa marca para reconocer esas páginas otra vez, soltando la capa vieja y conservando el contenido original de la página

Options := THPDFOCRTextLayerOptions.Default;
Options.ReplaceExistingOCRTextLayer := True;  // cambia nuestra capa anterior
Options.UseOptionalContentGroup := True;      // capa llamada '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 archivo OCR'd por un driver de escáner no carga tal marca, así que sus páginas siguen contando como páginas con texto. Apagar SkipPagesWithText apilaría una segunda capa invisible sobre la primera, y todo resultado de búsqueda aparecería dos veces. Deje esos archivos quietos

¿Qué handles hereda el proceso de Tesseract?

El proceso hijo de Tesseract hereda exactamente dos handles de HotPDF: uno abierto sobre NUL para entrada y salida estándar, y uno para stderr.txt en el directorio privado. El cuelgue del pipe de arriba viene del propio CreateProcess: bInheritHandles = True es obligatorio para pasar handles estándar, y por sí solo pasa al hijo todo handle heredable del host, incluidos pipes, archivos y eventos abiertos por código sin relación. HotPDF usa STARTUPINFOEX con una lista de atributos que carga PROC_THREAD_ATTRIBUTE_HANDLE_LIST y el flag EXTENDED_STARTUPINFO_PRESENT. bInheritHandles se queda en True, pero solo cruzan los handles listados

Herencia de handles del proceso hijo de Tesseract en HotPDF: un CreateProcess corriente con bInheritHandles pasa al hijo cada 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 vivos objetos no relacionados hasta que sale, bloqueando archivos y dejando pipes sin fin; con ella, solo los dos handles listados cruzan la frontera

El mismo patrón va en todo otro proceso hijo que su aplicación arranque, porque un solo lanzador que hereda todo alcanza para colgar a todo lector de pipes del proceso:

// Patrón para sus propias llamadas CreateProcess (fragmento: NullHandle, ErrorHandle,
// Exe, CommandLine, WorkDir y ProcessInfo se declaran en otro lado).
// Las RTL de Delphi viejas no declaran las funciones de lista de atributos; agregue
// sus propias declaraciones external de kernel32 si el compilador no las encuentra
// Revise cada resultado BOOL en código de producción
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;         // creado con bInheritHandle = True
  HandleList[1] := ErrorHandle;        // igual
  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);
  // ... asigne dwFlags y hStdInput / hStdOutput / hStdError como siempre ...
  CreateProcess(PChar(Exe), PChar(CommandLine), nil, nil,
    True,                              // sigue siendo obligatorio con lista de handles
    CREATE_NO_WINDOW or EXTENDED_STARTUP_FLAG,
    nil, PChar(WorkDir), Startup.StartupInfo, ProcessInfo);
  // tras salir el hijo: DeleteProcThreadAttributeList, FreeMem, CloseHandle
end;

Los scripts puente agregan un detalle más de Windows. Un launcher de Python puede arrancar un segundo proceso intérprete, y terminar el proceso que usted creó deja ese corriendo. Cuando al engine se le da una ruta de script, como el adaptador RapidOCR de abajo, HotPDF arranca el hijo suspendido, lo asigna a un job object con JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE y luego lo reanuda, así que cerrar el job termina todo el árbol. La misma idea de contención impulsa aislar codecs de imagen PDF en procesos worker, donde el hijo no es de confianza; aquí el hijo es de confianza, pero su proceso no es el único código sosteniendo handles dentro

Cancelación, timeouts y la confirmación todo-o-nada

IHPDFOCREngine.Recognize devuelve un Boolean, y False cubre tanto "Tesseract falló" como "el usuario pulsó Cancelar". El adaptador consulta el token y su timeout cada 25 milisegundos mientras Tesseract corre, y ante una cancelación limpia y devuelve False. Tomado a la letra eso se leería como otlsEngineError, así que ApplyLoadedOCRTextLayer revisa el token primero cada vez que Recognize devuelve False. El reconocimiento y la construcción de contenido terminan para toda página pedida antes de que se abra la transacción del documento, así que una cancelación en la página 40 de 50 reporta otlsCancelled y deja las 50 páginas tal como se cargaron. Los demás límites siguen el mismo estilo:

  • El timeout corre desde el inicio de cada llamada Recognize, así que el default de 60,000 ms es un límite por página
  • Un hijo que siga corriendo al expirar el timeout o ante una cancelación se termina y se espera hasta 5 segundos; el directorio privado se elimina en un bloque finally, con el borrado reintentado hasta por un segundo porque los archivos de salida pueden quedar bloqueados un momento tras terminar el proceso
  • output.tsv tiene tope de 64 MiB y stderr.txt de 1 MiB, revisados mientras el hijo corre y después de salir
  • Las palabras y unidades de código UTF-16 por página tienen tope según lo que quede de MaxWordsPerPage, MaxTotalWords y MaxTextCodeUnits; pasarse hace fallar la corrida en vez de truncar la lista
  • Un código de salida distinto de cero regresa con hasta 4,096 caracteres de stderr.txt, la manera más rápida de enterarse de que falta un archivo .traineddata

Cómo se ve la capa de texto invisible dentro de la página

HotPDF escribe las palabras con el modo de renderizado de texto 3 (ni relleno ni trazo, ISO 32000-1 §9.3.6), así que el escaneo sigue visible mientras la búsqueda y la copia trabajan sobre las palabras. El content stream abre BT con 3 Tr; cada palabra recibe una matriz Tm en su baseline, un tamaño de fuente de la altura de caja en píxeles por 72 sobre el DPI de render, y una escala Tz que estira la corrida al ancho de la caja. Ese estirado mantiene el resaltado de búsqueda sobre la palabra en la imagen

El TSV trae cajas pero no baselines, así que cada baseline se coloca a un quinto de la altura de caja sobre el borde inferior. Entre palabras vecinas de una línea izquierda-derecha en scripts separados por espacios como el latino o el cirílico, un espacio estirado llena el hueco, así que el texto copiado conserva sus cortes de palabra; el chino no los recibe. Los glifos van por una fuente Type0 compartida sin embeber con codificación Identity-H y un CMap ToUnicode generado, un CID por escalar Unicode distinto, que es cómo el chino, el latino acentuado y los caracteres del plano complementario sobreviven la copia y la búsqueda. Una corrida sostiene hasta 65,535 escalares distintos, y como ISO 19005 exige fuentes embebidas, la salida PDF/A necesita una fuente conforme embebida aparte. Para verificar un resultado, guarde, recargue y corra el camino ordinario de extraer texto de un PDF cargado en Delphi; si las palabras esperadas regresan en las páginas esperadas, la capa es real

RapidOCR y la DLL nativa sobre el mismo contrato

Para escaneos en chino simplificado, HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) reutiliza el mismo corredor de procesos y el mismo parser de TSV. La ruta del script puente va tras el ejecutable de Python y el idioma queda fijo en chi_sim. HotPDF distribuye el puente como tools/OCR/rapidocr_tsv.py; necesita los packages rapidocr y onnxruntime, modelos ONNX locales y un archivo de fuente, se niega a descargas automáticas, y escribe TSV con forma de Tesseract; un overload de THPDFRapidOCROptions asigna archivos de modelo, conteos de threads y un tope de píxeles. La factory genérica detrás de ambos adaptadores, HPDFCreateLocalTSVOCREngine en HPDFLocalTSVRecognition, acepta cualquier programa que tome los argumentos estilo Tesseract y escriba el TSV de doce columnas, y hereda la lista de handles, el timeout, la cancelación y los presupuestos

HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language) carga en cambio la API C de Tesseract in-process, se reporta como Tesseract (native DLL), verifica de entrada que cada archivo .traineddata pedido exista, y mapea cancelación y timeout sobre el monitor de progreso propio de Tesseract. Se ahorra un arranque de proceso y un viaje de BMP por página; el ejecutable mantiene los crashes y el crecimiento de memoria del reconocedor fuera de su address space. Para una herramienta de escritorio que trabaja los escaneos del propio usuario, la DLL está bien. Para un servicio que ingiere documentos de extraños, el engine de línea de comandos es el default más seguro. En cualquier caso el reconocimiento corre en Windows, una página por vez, sobre el bitmap renderizado, así que enderece y limpie los escaneos difíciles antes de que lleguen al engine

Los adaptadores OCR, el escritor de la capa de texto, el renderer que los alimenta y la extracción que verifica la salida viven todos en un componente VCL nativo para Delphi y C++Builder. Si su aplicación de captura o archivado necesita salida PDF buscable, el componente PDF de HotPDF para Delphi cubre todo salvo el engine OCR que usted instala