Artigo Técnico

OCR de chinês e multilíngue no Delphi com RapidOCR e HotPDF

O HotPDF faz OCR de chinês e multilíngue em Delphi através do adapter nativo de DLL RapidOCR dele: o THPDFRapidOCRDLLOptions.ForLanguage mapeia uma tag de idioma como 'zh-CN', 'zh-TW', 'ru' ou 'ar' para um par matched de recognition model e character dictionary, e o THotPDF.ApplyLoadedOCRTextLayer transforma as linhas reconhecidas numa camada de texto Unicode invisível e pesquisável em páginas de PDF escaneadas

Fazer uma demo em script latino funcionar é a parte fácil. As falhas interessantes começam quando você troca para chinês tradicional ou russo e o output vira nonsense confiante e bem formado, ou quando toda linha perde silenciosamente o último caractere, ou quando uma página em árabe volta com as text boxes delas na ordem errada. Nada disso levanta exceção por conta própria. Os presets de idioma acrescentados no HotPDF v2.775.0 existem sobretudo para fechar essas brechas, e as quatro armadilhas abaixo valem a pena entender mesmo que você nunca toque no código nativo, porque cada uma explica um sintoma pelo qual você de outro modo gastaria um dia caçando

Como o ForLanguage escolhe um model e um dictionary?

O THPDFRapidOCRDLLOptions.ForLanguage resolve uma tag para um de nove perfis e retorna options que apontam para <profile>/recognition.onnx e <profile>/dictionary.txt abaixo do seu model directory, mantendo o detector compartilhado, o angle classifier opcional, e os defaults de thread, pixel e timeout do THPDFRapidOCRDLLOptions.Default. O método converte a tag para minúsculas, vira underscores em hyphens e apara whitespace nas pontas, então 'zh_TW', 'ZH-tw' e ' zh-tw ' todos pousam no mesmo perfil. Aliases são uma lista explícita em vez de um prefix match: 'zh-Hant-TW' é aceito porque está listado, enquanto uma variante regional arbitrária que não esteja levanta EArgumentException antes de qualquer model ser carregado

Resolução de perfil do ForLanguage no HotPDF para THPDFRapidOCRDLLOptions: tags como zh_TW, ZH-tw e zh-TW são normalizadas e casadas contra nove perfis listados, cada um pregando um recognition model e dictionary que são sempre setados juntos, enquanto uma tag não listada levanta EArgumentException antes de qualquer model carregar
uma tag seleciona um par model-dictionary pregado; o detector, o classifier e os budgets continuam compartilhados, e uma tag desconhecida falha rápido em vez de carregar qualquer coisa
PerfilIdiomasTags de exemploModel pregado
chChinês simplificado e inglêszh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_chtChinês tradicionalzh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
enInglêsen, en-US, en-GB, engPP-OCRv4
latinFrancês, alemão, espanhol, português, italiano, holandês, turcofr, de, es-419, pt-BR, trPP-OCRv3
japanJaponêsja, ja-JP, jpnPP-OCRv4
koreanCoreanoko, ko-KR, korPP-OCRv4
cyrillicRusso, ucraniano, búlgaro, bielorrussoru, ru-RU, uk, bgPP-OCRv3
arabicÁrabe, persa, urduar, ar-SA, fa, urPP-OCRv4
devanagariHindi, marata, nepalêshi, mr, nePP-OCRv4

O adapter em si nunca baixa nada. Você provisiona os arquivos uma vez com o helper embarcado, por exemplo tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic (ou -Language All para todos os nove perfis), e o helper coloca um detector e um classifier compartilhados nos filenames na raiz que o Default espera. Depois disso, um scan em chinês simplificado fica pesquisável com umas poucas linhas. A parte de plumbing da engine é a mesma emenda IHPDFOCREngine descrita no artigo sobre a DLL RapidOCR in-process e a fronteira de ABI dela, então este fica focado nos idiomas

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeChineseScanSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Models: THPDFRapidOCRDLLOptions;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ch/recognition.onnx + ch/dictionary.txt, detector e classifier compartilhados
  Models := THPDFRapidOCRDLLOptions.ForLanguage('zh-CN');
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Layer := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    // uma lista de páginas vazia significa todas as páginas; páginas que já têm texto são puladas
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Layer, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines, ', Info.UniqueScalarCount, ' distinct characters');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

Dois detalhes nesse output merecem nota. O pipeline nativo retorna um resultado por linha de texto detectada, não por palavra, então o AcceptedWordCount conta linhas aqui, e o MinimumConfidence é comparado contra a confidence média de caractere da linha inteira: uma linha com média 0.45 é descartada como uma unidade. O UniqueScalarCount reporta quantos Unicode scalars distintos a camada de texto teve que mapear na fonte e na tabela ToUnicode dela, um sanity check útil de que texto CJK de fato chegou em vez de um punhado de fallbacks latinos. Mantenha a interface da engine viva entre documentos, porque a inicialização dos models acontece na factory e é o passo caro

Por que trocar só o recognition model produz lixo?

Um recognition model CTC nunca produz caracteres, só índices de classe, e o dictionary é a única coisa que transforma o índice 1.204 num glyph. Troque o ch/recognition.onnx pelo cyrillic/recognition.onnx mas mantenha o dictionary chinês, e o model vai emitir felicemente índices de cirílico válidos que o dictionary velho traduz em caracteres Han aleatórios. O resultado parece texto, passa validação UTF-8, e é pesquisável por exatamente nada. É por isso que o ForLanguage sempre seta RecognitionModel e CharacterDictionary juntos, e por que options construídas à mão nunca devem mudar um sem o outro

A checagem de segurança óbvia, comparar o tamanho do dictionary com a largura de output do model, é necessária mas não suficiente. Dois dictionaries podem ter o mesmo número de entradas numa ordem diferente, e um off-by-one na ordem desloca cada caractere por um code point. O HotPDF portanto confere em dois estágios quando a factory inicializa o model. Primeiro, a contagem de classes de output precisa igualar as entradas do dictionary mais duas. Segundo, quando o arquivo ONNX embute uma lista de metadata character, cada entrada do dictionary é comparada com ela em ordem, e um desencontro falha a inicialização com EInvalidOperation e um diagnóstico nativo em vez de produzir lixo plausível mais tarde

O "mais duas" vem do layout de classes. A classe 0 é o blank CTC, as classes 1 a N são as linhas do dictionary em ordem de arquivo, e a classe final é um espaço. Alguns dictionaries também carregam a própria entrada de espaço deles, e essa linha precisa ser mantida exatamente como é. É aqui que um Trim bem-intencionado causa dano real: ele transforma uma entrada de espaço único numa string vazia e desloca ou quebra a tabela. A única normalização segura é remover um carriage return final, então um dictionary salvo com line endings CRLF carrega corretamente, enquanto um byte order mark UTF-8, uma linha vazia, ou uma entrada contendo um tab é rejeitada. O esqueleto abaixo mostra o layout em Pascal; é código explicativo, não uma API do HotPDF

Layout da class table CTC no HotPDF para dictionaries RapidOCR: a classe 0 é o blank, as classes 1 a N são as linhas do dictionary em ordem de arquivo com qualquer entrada de espaço solitário mantida, e a classe final é um espaço, dando N mais 2 classes de output que a factory verifica contra o model, metadata incluída
o dictionary é a única coisa transformando índices de classe em caracteres, então o tamanho, a ordem e a entrada de espaço dele são verificados antes de uma única página ser reconhecida
// Só ilustração: a class table que um recognizer CTC espera
uses
  SysUtils, IOUtils;

function BuildCTCClassTable(const FileName: string): TArray<string>;
var
  Text, Entry: string;
  Lines: TArray<string>;
  I, Last: Integer;
begin
  Text := TEncoding.UTF8.GetString(TFile.ReadAllBytes(FileName));
  if (Text <> '') and (Text[1] = #$FEFF) then
    raise EArgumentException.Create('Dictionary must be UTF-8 without a BOM');
  Lines := Text.Split([#10]);
  Last := High(Lines);
  if (Last >= 0) and (Lines[Last] = '') then
    Dec(Last);                                   // newline no fim do arquivo
  SetLength(Result, Last + 3);
  Result[0] := '';                               // classe 0: blank CTC
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF: descarte só o CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // nunca Trim: ' ' é uma classe
  end;
  Result[Last + 2] := ' ';                       // classe final: espaço
  // Length(Result) precisa igualar a contagem de classes do output do model
end;

O que o greedy CTC decoding realmente faz?

O greedy CTC decoding escolhe a classe de maior score a cada time step, colapsa repetições consecutivas num caractere, e descarta a classe blank; o blank é o que permite que letras genuinamente dobradas sobrevivam. Um recognition model olha uma linha de texto como uma sequência de fatias verticais estreitas, e para cada fatia, ou time step, ele produz uma probabilidade para cada classe. Uma linha contendo AA中 pode produzir a sequência argmax A A blank A 中 space. Colapsar os dois primeiros steps de A dá um A, o blank o separa do próximo A, e o resultado é AA中 com o espaço final intacto. Sem a regra do blank, book e bok seriam indistinguíveis

Passo a passo do GreedyCTCDecode no HotPDF: seis time steps votam classes argmax A, A, blank, A, um caractere Han e espaço, repetições consecutivas colapsam, o blank reseta a guarda de repetição para uma letra genuinamente dobrada sobreviver, e três bugs de fronteira descartam silenciosamente espaçamento de palavras, o último caractere ou caracteres dobrados
o decoder é uma dúzia de linhas e toda fronteira importa: inclua a última classe, inclua o último step, e deixe só um blank separar repetições

Como o decoder tem só uma dúzia de linhas, é fácil errar as fronteiras, e as falhas são silenciosas. Se o loop argmax interno para uma classe antes, a classe de espaço nunca pode vencer e toda linha volta sem espaçamento de palavras, o que destrói phrase search em páginas inglesas e latinas. Se o loop externo para um time step antes, o último caractere de toda linha desaparece, o que numa linha curta pode ser um terço do texto. E se a guarda de repetição não é resetada por um blank, caracteres dobrados como ll ou reduplicações chinesas como 谢谢 colapsam num só. O decoder do HotPDF inclui a última classe e o último time step, mantém repetições separadas por blank, e adicionalmente rejeita scores que não são finitos ou caem fora de 0 a 1, e qualquer contagem de classes que não bata com o dictionary. Aqui está a mesma lógica como ilustração Pascal

// Só ilustração: greedy CTC decoding com fronteiras corretas.
// Scores guarda Steps * Classes probabilidades, uma linha por time step
function GreedyCTCDecode(const Scores: array of Single;
  Steps, Classes: Integer; const Characters: array of string): string;
var
  Step, C, Best, Previous: Integer;
  BestScore: Single;
begin
  if (Classes < 3) or (Length(Characters) <> Classes) or
    (Length(Scores) <> Steps * Classes) then
    raise EArgumentException.Create('Model output does not match the dictionary');
  Result := '';
  Previous := 0;                            // classe 0 é o blank CTC
  for Step := 0 to Steps - 1 do             // inclua o último time step
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // inclua a última classe (espaço)
      if Scores[Step * Classes + C] > BestScore then
      begin
        Best := C;
        BestScore := Scores[Step * Classes + C];
      end;
    if (Best <> 0) and (Best <> Previous) then
      Result := Result + Characters[Best];
    Previous := Best;                       // um blank reseta a guarda de repetição
  end;
end;

Greedy decoding não é a estratégia CTC mais precisa disponível; beam search com um language model pode consertar algumas fatias ambíguas. Para documentos impressos a 300 DPI o resultado greedy costuma ser o que o model tem para oferecer, e o decoder não é o lugar para compensar fraquezas do model. O model latino PP-OCRv3, por exemplo, pode ler ñ como n mesmo em input limpo. O HotPDF não disfarça isso com replacements de caracteres em post-processing, porque uma substitution table que conserta o espanhol quebra outra coisa, e um caractere errado numa camada pesquisável é pior do que um miss honesto

Como o HotPDF ordena linhas de texto, inclusive árabe da direita para a esquerda?

O HotPDF ordena as text boxes detectadas de cima para baixo, agrupa boxes numa linha quando elas se sobrepõem verticalmente em pelo menos metade da altura da box menor, e ordena cada linha da esquerda para a direita, ou da direita para a esquerda quando o RightToLeft está habilitado; os caracteres dentro de cada linha reconhecida nunca são invertidos. O agrupamento importa porque um detector costuma partir uma linha visual em várias boxes, por exemplo um label e um value separados por um vão largo, e uma ordenação pura por coordenada de topo os intercalaria com a linha vizinha sempre que os topos deles diferissem por um pixel ou dois

O preset árabe seta RightToLeft := True, o que diz à DLL ordenar as boxes de cada linha pela borda direita delas, da margem direita para dentro. Esse é todo o efeito. O texto que o model retorna para uma linha já está em ordem lógica Unicode, a ordem em que um leitor de árabe lê e digita, e essa também é a ordem que a extração de texto e a busca de PDF esperam. Inverter mecanicamente a string para que ela "pareça certa" num debugger quebraria busca, cópia e colagem, e screen readers. Display bidirecional e glyph shaping são trabalho do viewer

Uma engine serve um language profile. Não existe detecção automática de script, então um documento que mistura scripts precisa de uma engine por perfil, aplicada às páginas que o usam. Como o ApplyLoadedOCRTextLayer recebe uma lista de páginas explícita e efetiva cada chamada como a própria transação all-or-nothing dela, isso é direto

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // levanta EArgumentException para tag desconhecida, antes de qualquer model carregar
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // espaço para páginas A3 a 300 DPI
  Result := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
end;

procedure OCRMixedArchive(Doc: THotPDF);
var
  Chinese, Arabic: IHPDFOCREngine;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Chinese := CreateRapidEngine('zh-TW');  // chinese_cht profile
  Arabic := CreateRapidEngine('ar-SA');   // arabic profile, RightToLeft = True
  Layer := THPDFOCRTextLayerOptions.Default;
  if not Doc.ApplyLoadedOCRTextLayer([0, 1, 2], Chinese, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
  if not Doc.ApplyLoadedOCRTextLayer([3], Arabic, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
end;

A linha do MaxPixels está ali por um motivo. As options da DLL têm como default 16.777.216 pixels por request, o que cobre A4 e US Letter a 300 DPI com folga, mas uma página A3 a 300 DPI tem uns 3508 por 4961 pixels, cerca de 17,4 milhões, e o request é recusado como acima do orçamento. Suba o MaxPixels (o teto é 67.108.864) ou baixe o THPDFOCRTextLayerOptions.DPI para formatos grandes. A ordenação da direita para a esquerda usa o export opcional HPDFRapidOCRSetReadingDirection da ABI version 1; o adapter só o exige quando o RightToLeft está setado, então uma DLL mais velha continua servindo idiomas da esquerda para a direita e falha na criação da engine com um EArgumentException nomeando o export faltante para árabe

Por que models OCR mais novos falham ao carregar?

A DLL RapidOCR do HotPDF linka um ONNX Runtime 1.14 estático, que não consegue ler models salvos com ONNX IR version 10, e exports mais novos como os models PP-OCRv5 podem exigir uma runtime mais nova que essa; tal model falha na criação da engine com um diagnóstico nativo. Essa restrição é a razão de os language packs estarem pregados a pares específicos de recognizer e dictionary PP-OCRv3 e PP-OCRv4 em vez de "latest", e de a tabela acima misturar as duas gerações: todo par pregado é um que carrega e verifica sob aquela runtime

O installer impõe o pareamento. Todo arquivo no manifest dele carrega um hash SHA256, um arquivo existente com hash diferente para o install em vez de ser sobrescrito, e cada download pousa sob um nome temporário e só se move para o lugar depois de o hash dele bater. Isso protege contra a versão silenciosa do problema de dictionary: alguém derruba um recognition.onnx mais novo numa pasta de perfil na mão, a contagem de classes por acaso bate, e nada falha até um cliente reportar que a busca não acha palavras que ele vê claramente. Em runtime o adapter fica offline e nunca busca um model faltante. O recognizer também valida a shape do model no load, aceitando input NCHW com altura fixa de 32 ou 48 pixels ou altura dinâmica, que ele roda a 48

Se você precisa de um script que nenhum dos nove perfis cobre, ainda pode apontar RecognitionModel e CharacterDictionary para os seus próprios arquivos. As mesmas checagens se aplicam, e esse é o ponto: um par desalinhado falha na inicialização, não na archive do seu cliente. Para páginas em que nenhum perfil RapidOCR se encaixa, o adapter Tesseract para PDF pesquisável se pluga na mesma chamada de ApplyLoadedOCRTextLayer, e para formulários ASCII impressos por máquina a engine OCR embutida de template matching não precisa de models de forma alguma

Referência rápida: checklist RapidOCR multilíngue

  • Crie options com o THPDFRapidOCRDLLOptions.ForLanguage e trate EArgumentException como tag não suportada, não como falha de runtime
  • Mude RecognitionModel e CharacterDictionary juntos, nunca um sozinho; contagens de classes iguais não provam ordem de caracteres igual
  • Mantenha dictionaries como UTF-8 sem BOM, nunca apague entradas, e espere que o model tenha N + 2 classes: blank, N entradas, espaço
  • Um decoder CTC custom precisa cobrir a última classe e o último time step e manter repetições separadas por blank
  • Use uma engine por language profile e passe listas de páginas explícitas para documentos de scripts mistos
  • O RightToLeft muda só a ordem das boxes; o texto reconhecido permanece em ordem lógica Unicode
  • Instale models com o Install-RapidOCRModels.ps1 para que os pins SHA256 segurem o pareamento model e dictionary; sete UseAngleClassifier := False se você instalou com -SkipClassifier
  • Suba o MaxPixels acima do default 16.777.216 antes de rodar páginas A3 ou maiores a 300 DPI

Os presets de idioma RapidOCR, o adapter nativo de DLL e o pipeline de camada de texto OCR fazem parte do HotPDF Delphi PDF Component para Delphi, C++Builder e Windows FPC/Lazarus, começando com a v2.775.0 para os perfis multilíngues