Artigo Técnico

Tesseract OCR para PDF pesquisável no Delphi com HotPDF

Um pipe que nunca reporta end-of-file é um bug estranho de caçar numa feature de OCR. Uma thread na sua aplicação lê o standard output de um programa auxiliar e espera o ReadFile falhar com ERROR_BROKEN_PIPE. Outra thread inicia o tesseract.exe através de CreateProcess com bInheritHandles setado para True, porque é assim que se entrega a um processo filho um arquivo para o standard error dele. O write end do pipe do auxiliar era herdável, então o Tesseract agora possui uma cópia, e a thread de leitura pende até o Tesseract terminar a página dele — o que, num scan de 300 DPI com dois language models carregados, não é rápido

Um recognizer externo traz mais que o hang: o que o filho pode ver, o que fazer com output meio válido, como transformar boxes de pixels em texto PDF pesquisável. A engine OCR de template matching integrada cobre letras e dígitos ASCII impressos por máquina de propósito. Nomes acentuados, contratos em chinês e arquivos multilíngue precisam de language models treinados, e o Tesseract é o candidato usual porque é um programa de linha de comando que você pode instalar ao lado do executável

Encaixando o Tesseract na camada de texto OCR do HotPDF

O HotPDF embrulha um executável Tesseract instalado localmente como uma IHPDFOCREngine através do HPDFCreateTesseractOCREngine na unit HPDFTesseractRecognition. Você passa essa engine ao THotPDF.ApplyLoadedOCRTextLayer, que renderiza cada página pedida, roda o Tesseract uma vez por página, filtra as palavras e escreve uma camada de texto Unicode invisível para todas as páginas pedidas numa única transação, ou para nenhuma delas. Mapeamento de coordenadas, rotação, filtragem de confidence e o commit são o pipeline que a engine integrada já usa; só o reconhecimento muda

Pipeline OCR do HotPDF por página: renderize a página no DPI configurado, salve input.bmp num diretório HotPDF-OCR privado, lance o processo filho Tesseract com tessedit_create_tsv, parseie o TSV de doze colunas, filtre palavras por confidence, e efetive a camada de texto invisível para todas as páginas pedidas ou nenhuma
O adapter só troca o reconhecimento: rendering, parsing, validação e o commit all-or-nothing ficam no pipeline de camada de texto existente, então código downstream nunca muda

A factory levanta EArgumentException antes de qualquer página ser tocada. O executável e o diretório tessdata precisam existir, o timeout precisa estar entre 1 e 3.600.000 milissegundos, e o idioma pode conter só letras ASCII, dígitos, _ e +, porque ele acaba numa linha de comando: eng+chi_sim é legítimo, um valor com aspas ou espaços é uma injeção esperando para acontecer. Um segundo overload recebe um record THPDFTesseractOptions. O Default dele dá --psm 3, --oem 3, 60 segundos por página e um cap de 16.777.216 pixels, e ele rejeita tpsOSDOnly e tpsAutoOnly, os dois modos que não retornam palavras

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;    // vale para cada página
  // levanta EArgumentException para executável ou pasta tessdata ausente,
  // um identificador de idioma inválido, ou options fora das faixas delas
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',                              // models unidos com '+'
    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;
    // lista de páginas vazia significa todas as páginas; páginas que já têm texto são puladas
    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;

Faça a aritmética de pixels uma vez. Uma página A4 a 300 DPI dá cerca de 2.481 por 3.508 pixels, aproximadamente 8,7 milhões, e cabe. Uma página A3 na mesma resolução é cerca de 3.508 por 4.961, perto de 17,4 milhões, então ela falha com um diagnóstico de pixel budget e a run termina como otlsEngineError. O overload que recebe só um timeout não aplica cap de pixel nenhum. Para um serviço que aceita arquivos de fora, mantenha o overload de options e suba o MaxPixels (até 67.108.864) ou baixe o THPDFOCRTextLayerOptions.DPI deliberadamente quando formatos grandes são esperados

Por página, a engine cria um diretório privado HotPDF-OCR-{GUID} sob o temp path, salva o bitmap como input.bmp e roda tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, adicionando --oem quando um engine mode está definido. Paths são postos entre aspas com as regras do Windows para barras invertidas e aspas embutidas, e o --dpi carrega a resolução de render para que o Tesseract nunca a chute. O Tesseract e os arquivos .traineddata dele não vêm no pacote; provisioná-los é trabalho da aplicação. Nas nossas próprias máquinas de teste Linux o recognizer roda de um bundle de nível de usuário do binário, do closure de shared libraries dele e da pasta tessdata, porque depender de /usr/bin/tesseract só funciona onde alguém instalou o pacote do sistema

Por que uma linha TSV quebrada esvazia a página inteira?

O adapter Tesseract falha a página inteira na primeira linha malformada, porque uma lista de palavras parcialmente parseada produz uma camada de texto que discorda da imagem, e nada no PDF salvo diz quais palavras sumiram. O output precisa decodificar como UTF-8 estrito sem NUL embutido. A primeira linha precisa igualar o header de doze colunas do Tesseract, de level a text, depois de um byte order mark opcional ser removido. Cada linha precisa se dividir em exatamente doze campos, e o split para depois do décimo primeiro tab, então um tab dentro do texto reconhecido cai no campo de texto em vez de criar uma décima terceira coluna, onde a checagem de caracteres de controle abaixo então o rejeitaria

Só linhas de level 5 são palavras; linhas de page, block, paragraph e line são puladas, assim como palavras vazias ou só de whitespace. Uma palavra contendo um caractere de controle (abaixo de U+0020, ou U+007F a U+009F) falha a página. O resto precisa de geometria inteira, uma confidence parseada com formatação invariante para que uma aplicação com locale alemão ainda leia 93.5, uma box totalmente dentro do bitmap, e uma confidence de 0 a 100. Uma palavra válida seguida de uma linha quebrada rende zero palavras

Seis gates que toda linha TSV do Tesseract passa no HotPDF: header exato de doze colunas, exatamente doze campos, só level 5, texto não vazio, uma box dentro do bitmap, e confidence de 0 a 100 parseada invariantemente, em que uma linha quebrada falha a página inteira até zero palavras
Uma lista de palavras parcialmente parseada discordaria silenciosamente da imagem, então o parser recusa a página inteira na primeira linha malformada em vez de manter as palavras que já leu

Uma linha esquisita portanto falha a run inteira e deixa todas as páginas intocadas. Esse é o trade certo. Uma rerun é barata; um PDF pesquisável em que uma busca por um número de nota fiscal erra a única página que importa costuma aparecer meses depois, nas mãos de outra pessoa

O último passo também encontra um default que você talvez não espere. O Tesseract reporta confidence de 0 a 100, o pipeline divide por 100, e o THPDFOCRTextLayerOptions.MinimumConfidence tem default 0.5, então qualquer palavra abaixo de 50 cai no Info.DroppedWordCount em vez de na página. Num scan limpo esse piso é sensato. Num fax ruidoso ele pode remover uma grande fatia do texto, então compare o Info.ReceivedWordCount com o Info.AcceptedWordCount antes de baixá-lo

Rodando OCR de novo num arquivo que já tem texto

O SkipPagesWithText é True por default, e isso pega as pessoas na segunda vez. Suponha que um lote foi processado com eng e agora precisa de eng+deu: toda página já carrega texto invisível, então o Info.SkippedPageCount iguala a contagem de páginas. O HotPDF marca o content stream que escreve como a própria camada OCR dele, e o ReplaceExistingOCRTextLayer usa essa marca para reconhecer essas páginas de novo, descartando a camada velha e mantendo o conteúdo original da página

Options := THPDFOCRTextLayerOptions.Default;
Options.ReplaceExistingOCRTextLayer := True;  // troca a nossa camada anterior
Options.UseOptionalContentGroup := True;      // camada nomeada '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));

Um arquivo com OCR feito por um driver de scanner não carrega tal marca, então as páginas dele ainda contam como páginas com texto. Desligar o SkipPagesWithText empilharia uma segunda camada invisível sobre a primeira, e todo hit de busca apareceria duas vezes. Deixe esses arquivos em paz

Quais handles o processo Tesseract herda?

O processo filho Tesseract herda exatamente dois handles do HotPDF: um aberto em NUL para standard input e output, e um para o stderr.txt no diretório privado. O hang de pipe acima vem do próprio CreateProcess: bInheritHandles = True é requerido para passar standard handles, e por si só ele passa todo handle herdável no host, incluindo pipes, arquivos e events abertos por código sem relação. O HotPDF usa STARTUPINFOEX com uma attribute list carregando PROC_THREAD_ATTRIBUTE_HANDLE_LIST e a flag EXTENDED_STARTUPINFO_PRESENT. O bInheritHandles continua True, mas só os handles listados cruzam

Herança de handles do processo filho Tesseract no HotPDF: um CreateProcess simples com bInheritHandles passa todo handle herdável de arquivo, pipe e event ao filho, enquanto STARTUPINFOEX com PROC_THREAD_ATTRIBUTE_HANDLE_LIST limita o conjunto a um handle NUL para stdin e stdout mais o handle do arquivo stderr
Sem a attribute list o filho mantém objetos sem relação vivos até sair, travando arquivos e matando pipes de fome; com ela, só os dois handles listados cruzam a fronteira

O mesmo padrão pertence a todo outro processo filho que a sua aplicação iniciar, já que um launcher que herda tudo basta para pendurar todo leitor de pipe no processo:

// Padrão para as suas próprias chamadas CreateProcess (fragmento: NullHandle, ErrorHandle,
// Exe, CommandLine, WorkDir e ProcessInfo são declarados em outro lugar).
// RTLs Delphi mais velhas não declaram as funções de attribute list; adicione as suas
// declarações external próprias do kernel32 se o compilador não as encontrar
// Confira cada resultado BOOL em código de produção
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;         // criado com bInheritHandle = True
  HandleList[1] := ErrorHandle;        // idem
  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);
  // ... defina dwFlags e hStdInput / hStdOutput / hStdError como de costume ...
  CreateProcess(PChar(Exe), PChar(CommandLine), nil, nil,
    True,                              // ainda requerido com uma handle list
    CREATE_NO_WINDOW or EXTENDED_STARTUP_FLAG,
    nil, PChar(WorkDir), Startup.StartupInfo, ProcessInfo);
  // depois de o filho sair: DeleteProcThreadAttributeList, FreeMem, CloseHandle
end;

Bridge scripts adicionam mais um detalhe do Windows. Um launcher Python pode iniciar um segundo processo interpretador, e terminar o processo que você criou deixa aquele rodando. Quando a engine recebe um script path, como o adapter RapidOCR abaixo recebe, o HotPDF inicia o filho suspenso, o atribui a um job object com JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE e então o retoma, então fechar o job encerra a árvore inteira. A mesma ideia de contenção dirige isolar codecs de imagem PDF em processos worker, em que o filho não é confiável; aqui o filho é confiável, mas o seu processo não é o único código segurando handles nele

Cancelamento, timeouts e o commit all-or-nothing

O IHPDFOCREngine.Recognize retorna um Boolean, e False cobre tanto "o Tesseract falhou" quanto "o usuário apertou Cancel". O adapter consulta o token e o timeout dele a cada 25 milissegundos enquanto o Tesseract roda, e no cancelamento ele limpa e retorna False. Tomado ao pé da letra isso leria como otlsEngineError, então o ApplyLoadedOCRTextLayer confere o token primeiro sempre que o Recognize retorna False. Reconhecimento e construção de conteúdo terminam para toda página pedida antes de a transação do documento abrir, então um cancelamento na página 40 de 50 reporta otlsCancelled e deixa as 50 páginas como carregadas. Os outros limites seguem o mesmo estilo:

  • O timeout corre do início de cada chamada Recognize, então o default de 60.000 ms é um limite por página
  • Um filho ainda rodando no timeout ou cancelamento é terminado e esperado por até 5 segundos; o diretório privado é removido num bloco finally, com deleção retentada por até um segundo porque arquivos de output podem ficar travados brevemente depois de o processo terminar
  • O output.tsv é limitado a 64 MiB e o stderr.txt a 1 MiB, conferidos enquanto o filho roda e depois que ele sai
  • Palavras e code units UTF-16 por página são limitados pelo que resta de MaxWordsPerPage, MaxTotalWords e MaxTextCodeUnits; passar disso falha a run em vez de truncar a lista
  • Um exit code não zero volta com até 4.096 caracteres do stderr.txt, o caminho mais rápido para descobrir que um arquivo .traineddata está faltando

Como a camada de texto invisível fica dentro da página

O HotPDF escreve as palavras com text rendering mode 3 (nem fill nem stroke, ISO 32000-1 §9.3.6), então o scan continua visível enquanto busca e cópia trabalham sobre as palavras. O content stream abre BT com 3 Tr; cada palavra recebe uma matriz Tm na baseline dela, um font size de altura da box em pixels vezes 72 sobre o DPI de render, e uma escala Tz que estica a run até a largura da box. Esse esticamento mantém um highlight de busca sobre a palavra na imagem

O TSV tem boxes mas nenhuma baseline, então cada baseline é posta a um quinto da altura da box acima da borda inferior. Entre palavras vizinhas numa linha da esquerda para a direita em scripts separados por espaço como latim ou cirílico, um espaço esticado preenche o vão, então texto copiado mantém as quebras de palavra dele; chinês não recebe nenhuma. Glyphs passam por uma fonte Type0 compartilhada não embutida com encoding Identity-H e um CMap ToUnicode gerado, um CID por Unicode scalar distinto, que é como chinês, latim acentuado e caracteres de plano suplementar sobrevivem a cópia e busca. Uma run segura até 65.535 scalars distintos, e como o ISO 19005 exige fontes embutidas, output PDF/A precisa de uma fonte conformante embutida separadamente. Para conferir um resultado, salve, recarregue e rode o caminho comum de extrair texto de um PDF carregado no Delphi; se as palavras esperadas voltarem nas páginas esperadas, a camada é real

RapidOCR e a DLL nativa no mesmo contrato

Para scans de chinês simplificado, HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) reutiliza o mesmo runner de processo e parser TSV. O path do bridge script segue o executável Python e o idioma é fixado em chi_sim. O HotPDF embarca a bridge como tools/OCR/rapidocr_tsv.py; ela precisa dos pacotes rapidocr e onnxruntime, de models ONNX locais e de um arquivo de fonte, recusa downloads automáticos, e escreve TSV em formato Tesseract; um overload THPDFRapidOCROptions define model files, contagens de threads e um pixel cap. A factory genérica por trás dos dois adapters, HPDFCreateLocalTSVOCREngine em HPDFLocalTSVRecognition, aceita qualquer programa que receba os argumentos estilo Tesseract e escreva o TSV de doze colunas, e ela herda a handle list, o timeout, o cancelamento e os budgets

O HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language) carrega a C API do Tesseract in-process em vez disso, se reporta como Tesseract (native DLL), confere de cara que todo .traineddata pedido existe, e mapeia cancelamento e timeout para o progress monitor do próprio Tesseract. Ele economiza um start de processo e uma viagem de BMP por página; o executável mantém os crashes e o crescimento de memória do recognizer fora do seu address space. Para uma ferramenta desktop trabalhando nos scans do próprio usuário a DLL está ótima. Para um serviço que ingere documentos de estranhos, a engine de linha de comando é a escolha mais segura. De qualquer forma o reconhecimento roda no Windows, uma página por vez, sobre o bitmap renderizado, então faça deskew e limpeza de scans difíceis antes de eles chegarem à engine

Os adapters OCR, o writer de camada de texto, o renderer que os alimenta e a extração que confere o output vivem todos num único componente VCL nativo para Delphi e C++Builder. Se a sua aplicação de captura ou arquivamento precisa de output PDF pesquisável, o HotPDF Delphi PDF component cobre tudo exceto a engine OCR que você mesmo instala