Artigo Técnico

Tesseract OCR para PDF pesquisável no Delphi com HotPDF

O HotPDF transforma páginas de PDF digitalizado em PDF pesquisável com o Tesseract através do HPDFCreateTesseractOCREngine, uma fábrica que embrulha um executável Tesseract instalado localmente num IHPDFOCREngine. Passa esse motor ao ApplyLoadedOCRTextLayer, que renderiza cada página, corre o Tesseract uma vez por página, faz o parse do seu output TSV ao nível da palavra, e compromete uma camada de texto Unicode invisível para todas as páginas pedidas numa única transação, ou para nenhuma

Pipeline OCR do HotPDF por página: renderizar a página ao DPI configurado, guardar input.bmp num diretório privado HotPDF-OCR, lançar o processo filho Tesseract com tessedit_create_tsv, fazer o parse do TSV de doze colunas, filtrar palavras por confiança, e comprometer a camada de texto invisível para todas as páginas pedidas ou para nenhuma
O adaptador só substitui o reconhecimento: a renderização, o parse, a validação e o compromisso tudo-ou-nada ficam no pipeline de camada de texto existente, por isso o código a jusante nunca muda

A razão de existir deste adaptador é o âmbito. O motor OCR integrado de correspondência de templates é deliberadamente estreito: letras e dígitos ASCII impressos por máquina, e mais nada. Faturas com nomes acentuados, contratos em chinês, e arquivos multilingues precisam de um reconhecedor a sério com modelos de idioma treinados, e o Tesseract é o candidato óbvio porque é um programa de linha de comandos que se pode provisionar ao lado da aplicação. Chamar um programa externo a partir de uma biblioteca de documentos parece trivial. Não é, e a maior parte do código interessante do adaptador trata do que acontece quando o programa porta-se mal, bloqueia, é cancelado, ou herda coisas que nunca devia ver

Como conduz o HotPDF o Tesseract a partir de uma aplicação Delphi?

O HotPDF corre o Tesseract como um processo filho escondido por página, alimentando-o com um bitmap renderizado e lendo de volta um ficheiro TSV, e expõe o resultado pela mesma costura IHPDFOCREngine que o motor integrado usa. Nada a jusante muda: mapeamento de coordenadas, tratamento de rotação, validação Unicode, filtragem por confiança, e o compromisso atómico são o pipeline de camada de texto que já tem. A fábrica vive na unit HPDFTesseractRecognition e valida com antecedência: o executável tem de existir, o diretório tessdata tem de existir, o timeout tem de estar entre 1 e 3.600.000 milissegundos, e o identificador de idioma só pode conter letras ASCII, dígitos, _ e +. Essa última verificação interessa porque a string de idioma acaba numa linha de comandos, e eng+chi_sim é um valor Tesseract legítimo ao passo que qualquer coisa com aspas ou espaços não é

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // levanta EArgumentException por executável em falta, tessdata em falta,
  // um identificador de idioma mau, ou um timeout fora de 1..3600000 ms
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // vários modelos unidos com '+'
    120000);            // limite por página, o valor por defeito é 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;
    // uma lista de páginas vazia significa todas as páginas; páginas com texto são saltadas por defeito
    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, o Recognize cria um diretório privado sob o caminho temporário chamado HotPDF-OCR-{GUID}, guarda o bitmap renderizado como input.bmp, e lança tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, com todos os argumentos de caminho entre aspas pelas regras de escape de linha de comandos do Windows para barras invertidas e aspas incorporadas. O valor --dpi é o DPI de renderização de THPDFOCRTextLayerOptions.DPI, por isso o Tesseract nunca tem de adivinhar a resolução a partir dos metadados da imagem, e --psm 3 pede segmentação de página totalmente automática. O motor reporta-se como Tesseract (local CLI), que é o que aterra em Info.EngineName. O Tesseract e os seus modelos de idioma não vêm empacotados com o HotPDF; instalá-los é trabalho da aplicação

Porque é que o parser TSV é tão estrito?

O parser TSV do HotPDF falha a página inteira perante qualquer linha malformada, porque uma lista de palavras parcialmente analisada produz uma camada de texto que discrepa da imagem em silêncio. O output TSV do Tesseract tem um cabeçalho fixo de doze colunas, de level a text, e o HotPDF compara a primeira linha com esse cabeçalho exacto depois de retirar uma marca de ordem de bytes opcional. Cada linha seguinte tem de se dividir em exatamente doze campos, e a divisão para depois do décimo primeiro tab para que um tab dentro do texto reconhecido fique parte da palavra em vez de criar uma décima terceira coluna. Só as linhas de nível 5 são palavras; os níveis 1 a 4 descrevem páginas, blocos, parágrafos e linhas, e são saltados. Linhas de nível 5 cujo texto é vazio ou espaço em branco puro também são saltadas, porque uma palavra em branco tem caixa mas nada para localizar ou pesquisar. Todo o resto é verificado a sério: geometria inteira, uma confiança analisada com formato en-US invariante para que um locale alemão não leia 93.5 como lixo, uma caixa que caiba inteira dentro do bitmap, e uma confiança entre 0 e 100. Uma única falha levanta exceção, o motor devolve False, e o array de palavras é limpo. Os testes de regressão incluem exatamente esse caso: uma palavra válida seguida de uma linha partida tem de produzir zero palavras, e não uma

Seis gates que cada linha TSV do Tesseract passa no HotPDF: cabeçalho exato de doze colunas, exatamente doze campos, só nível 5, texto não vazio, uma caixa dentro do bitmap, e confiança de 0 a 100 analisada de forma invariante, em que uma linha partida falha a página inteira até zero palavras
Uma lista de palavras parcialmente analisada discreparia da imagem em silêncio, por isso o parser recusa a página inteira à primeira linha malformada em vez de ficar com as palavras que já leu
// condensado do ciclo de nível 5 em 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;                 // linhas de página/bloco/parágrafo/linha
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // palavras de espaço em branco não têm posição
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;  // o pipeline espera 0..1

Essa última linha interage com um valor por defeito que pode não esperar. A confiança do Tesseract corre de 0 a 100, o pipeline trabalha em 0 a 1, e o THPDFOCRTextLayerOptions.MinimumConfidence assume 0.5 por defeito, por isso qualquer palavra Tesseract abaixo de 50 é contada em Info.DroppedWordCount e nunca chega à página. Numa digitalização limpa a 300 DPI isso é um piso razoável. Num fax ruidoso pode deitar fora uma parte surpreendente da página, e a atitude certa é olhar à contagem de descartes antes de baixar o limiar, porque as palavras de baixa confiança são exatamente as mais prováveis de estar erradas

O que herda o processo filho Tesseract?

O processo filho Tesseract herda exatamente dois handles do HotPDF: um handle NUL para standard input e output, e um handle de ficheiro para standard error. Essa precisão é o ponto. O CreateProcess com bInheritHandles = True é a maneira de passar handles standard a um filho, mas por si só passa todos os handles herdáveis do processo anfitrião, incluindo ficheiros, pipes e eventos abertos por código alheio na sua aplicação. O filho mantém então esses objetos vivos até sair, por isso um ficheiro fica bloqueado ou um pipe nunca vê o seu fim enquanto o Tesseract moer uma página. O HotPDF fecha essa brecha com um registo de arranque estendido: STARTUPINFOEX, uma lista de atributos com PROC_THREAD_ATTRIBUTE_HANDLE_LIST, e a flag de criação EXTENDED_STARTUPINFO_PRESENT. Com a lista de handles no sítio, o bInheritHandles ainda tem de ser True, mas só os handles listados atravessam a fronteira. A mesma mentalidade de contenção conduz a isolar codecs de imagens PDF em processos de trabalho, em que o filho é código não confiável; aqui o filho é confiável, mas o anfitrião não é o único dono da sua própria tabela de handles

Herança de handles do processo filho Tesseract no HotPDF: um CreateProcess simples com bInheritHandles passa todos os handles herdáveis de ficheiro, pipe e evento ao filho, enquanto um STARTUPINFOEX com PROC_THREAD_ATTRIBUTE_HANDLE_LIST limita o conjunto a um handle NUL para stdin e stdout mais o handle de ficheiro do stderr
Sem a lista de atributos o filho mantém objetos alheios vivos até sair, bloqueando ficheiros e esfomeando pipes; com ela, só os dois handles listados atravessam a fronteira
// constantes mostradas pelo nome; a fonte passa os seus valores numéricos
// ambos os handles são criados com bInheritHandle = True
InheritedHandles[0] := NullHandle;    // stdin e stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt no diretório 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 pela lista de handles
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

Porque é que uma corrida OCR cancelada pode parecer uma falha de motor?

Uma corrida OCR cancelada parece uma falha de motor porque o IHPDFOCREngine.Recognize devolve um único Boolean, e False significa tanto «o Tesseract falhou» como «o utilizador carregou em Cancelar». O adaptador sonda o token de cancelamento e o timeout a cada 25 milissegundos enquanto o filho corre, e quando o token dispara levanta exceção dentro do Recognize, apanha a sua própria exceção, limpa, e devolve False com um diagnóstico. Se o pipeline tratasse isso como erro de motor, o chamador veria otlsEngineError para um job que o utilizador parou deliberadamente. O ApplyLoadedOCRTextLayer verifica portanto o token primeiro sempre que o Recognize devolve False, e só converte o resultado em falha de motor se o token não estava definido. Essa ordem preserva o contrato multi-página: reconhecimento, validação, contabilidade de budget e construção de conteúdo correm para todas as páginas pedidas antes de a transação do grafo abrir, por isso um cancelamento na página 40 de 50 reporta otlsCancelled e deixa o documento, incluindo as primeiras 39 páginas, intacto. Não há ficheiro parcialmente pesquisável para explicar mais tarde, e o resto do tratamento de falhas segue o mesmo estilo limitado:

  • O timeout é por chamada Recognize, medido a partir do seu início, por isso os 60.000 ms por defeito se aplicam a cada página em vez de ao documento inteiro
  • Um filho que continue a correr no timeout ou no cancelamento é terminado, esperado durante 5 segundos no máximo, e o seu diretório privado é apagado num bloco finally
  • O output.tsv está limitado a 64 MiB e o stderr.txt a 1 MiB, verificado enquanto o filho corre e depois de ele sair
  • A contagem de palavras e as code units UTF-16 são limitadas por página pelos budgets restantes MaxWordsPerPage, MaxTotalWords e MaxTextCodeUnits, e excedê-los falha a corrida em vez de truncar a lista de palavras
  • O standard output vai para NUL porque o Tesseract escreve o output.tsv, enquanto o standard error vai para um ficheiro, para que um código de saída não zero venha reportado com até 4.096 caracteres da queixa do próprio motor, normalmente a maneira mais rápida de descobrir que um ficheiro .traineddata está em falta

Como as palavras reconhecidas se tornam uma camada de texto invisível

O HotPDF escreve as palavras do Tesseract como texto invisível com o modo de renderização de texto 3, o modo nem-preencher-nem-traçar definido na ISO 32000-1 §9.3.6, por isso a página continua a mostrar a imagem digitalizada enquanto a pesquisa e a cópia trabalham sobre as palavras reconhecidas. O content stream abre BT com 3 Tr, e cada palavra recebe uma matriz Tm na sua linha de base, um tamanho de fonte derivado da altura da caixa em pixels ao DPI de renderização, e uma escala horizontal Tz que estica a corrida de glifos até à largura medida da caixa, e é por isso que um destaque de pesquisa cai sobre a palavra na imagem em vez de derivar por ela

O TSV do Tesseract tem caixas mas não linhas de base, por isso o adaptador reporta cada palavra sem linha de base e o pipeline estima a linha de base a um quinto da altura da caixa acima do bordo inferior. O texto em si passa por uma fonte Type0 não incorporada partilhada com codificação Identity-H e um CMap ToUnicode gerado, um CID por escalar Unicode distinto ao longo de toda a corrida, e é assim que chinês, latim acentuado e caracteres do plano suplementar sobrevivem todos à cópia e à pesquisa. Esse desenho tem dois limites que vale a pena enunciar desde já: uma corrida pode transportar no máximo 65.535 escalares distintos, e a fonte não incorporada não satisfaz o requisito de incorporação de fontes da ISO 19005, por isso output PDF/A precisa de uma fonte em conformidade incorporada à parte. Verificar o resultado é simples e vale a pena automatizar: grave, recarregue, e corra o caminho de texto vulgar de documento carregado de extrair texto de um PDF carregado no Delphi; se as palavras voltarem nas páginas esperadas, a camada é real

RapidOCR e outros motores no mesmo protocolo TSV

O HotPDF reutiliza o mesmo corredor de processos e parser TSV para o RapidOCR através do HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), que é a escolha mais útil para digitalizações em chinês simplificado. A linha de comandos é idêntica exceto que o caminho do script de ponte é inserido depois do executável Python, e o idioma é fixado em chi_sim. O HotPDF distribui a ponte como tools/OCR/rapidocr_tsv.py; espera os pacotes rapidocr e onnxruntime mais três modelos ONNX locais, desativa downloads automáticos de modelos, e escreve TSV com a forma do Tesseract para que o lado Delphi não precise de um segundo parser. O nome de motor reportado em Info.EngineName é RapidOCR (local ONNX). Essa forma sugere a receita geral: qualquer reconhecedor que consiga embrulhar num script pequeno que aceite a lista de argumentos ao estilo Tesseract e emita o TSV de doze colunas herda isolamento de handles, timeout, cancelamento, budgets de output e o compromisso tudo-ou-nada de borla. Os adaptadores são só para Windows, correm uma página de cada vez sincronamente, e não endireitam nem pré-processam a imagem além do que o renderizador produz, por isso a qualidade da imagem à entrada continua a fixar o teto do que sai

Os adaptadores Tesseract e RapidOCR, o escritor de camada de texto invisível, o renderizador de páginas que os alimenta, e a extração de texto que verifica o resultado saem todos no mesmo componente VCL nativo para Delphi e C++Builder. Se está a acrescentar OCR a uma aplicação de captura ou arquivo de documentos, o componente PDF HotPDF para Delphi dá-lhe o pipeline com só o próprio motor OCR por instalar